diff --git a/CALENDAR_GUIDE.md b/CALENDAR_GUIDE.md
new file mode 100644
index 0000000..47f2dd6
--- /dev/null
+++ b/CALENDAR_GUIDE.md
@@ -0,0 +1,111 @@
+# Календарь гидов и улучшенная система бронирования
+
+## 📅 Новая функция: Календарь гидов
+
+В разделе "Управление гидами" добавлен интерактивный календарь, который показывает:
+
+### Возможности календаря:
+- **Рабочие дни гидов** - отображение расписания по дням недели
+- **Выходные дни** - отпуска и нерабочие дни гидов
+- **Загруженность** - количество бронирований на каждый день
+- **Фильтрация** - возможность выбора конкретных гидов для отображения
+- **Навигация по месяцам** - просмотр расписания на разные периоды
+
+### Доступ к календарю:
+1. Через админ панель: Управление гидами → Расписание гидов → кнопка "📅 Открыть календарь"
+2. Через дашборд: главная страница админки → карточка "Календарь гидов"
+3. Прямая ссылка: `/admin/pages/calendar`
+
+### Легенда календаря:
+- 🟢 **Зеленый** - рабочий день, свободен
+- 🟡 **Желтый** - рабочий день, частично занят
+- 🔴 **Красный** - выходной день
+- ⚫ **Серый** - не работает в этот день недели
+
+## 🔄 Улучшенная система бронирования
+
+### Что изменилось:
+
+#### 1. Умный поиск туров
+- Поиск показывает **только доступные туры** на выбранную дату
+- Учитывается график работы гидов (дни недели, рабочие часы)
+- Проверяются выходные дни гидов
+- Учитывается текущая загруженность (до 3 групп в день на гида)
+
+#### 2. Улучшенная форма поиска
+- Добавлено поле "Направление" для поиска по локации
+- Обязательное указание даты
+- Выбор количества людей в группе
+- Мгновенные результаты с информацией о доступности
+
+#### 3. Проверка при бронировании
+Система автоматически проверяет:
+- Работает ли гид в выбранный день недели
+- Нет ли у гида выходного в эту дату
+- Есть ли свободные места (максимум 3 группы в день)
+- Соответствует ли размер группы ограничениям тура
+
+### API Endpoints:
+
+#### Календарь гидов:
+- `GET /api/guides` - список активных гидов
+- `GET /api/guide-schedules` - расписания работы
+- `GET /api/holidays` - выходные дни гидов
+- `GET /api/bookings` - существующие бронирования
+
+#### Поиск и бронирование:
+- `GET /api/search-available` - поиск доступных туров с учетом расписания
+- `POST /api/booking` - создание бронирования с проверкой доступности
+
+### Параметры поиска:
+```
+GET /api/search-available?destination=Seoul&date=2025-12-01&people=2
+```
+
+### Пример ответа:
+```json
+{
+ "success": true,
+ "data": [
+ {
+ "id": 1,
+ "title": "Тур по Сеулу",
+ "guide_id": 3,
+ "guide_name": "Ким Минджун",
+ "guide_available": true,
+ "available_slots": 2,
+ "price": 50000,
+ "start_time": "09:00",
+ "end_time": "18:00"
+ }
+ ]
+}
+```
+
+## 🎯 Преимущества новой системы:
+
+### Для администраторов:
+- Визуальный контроль загруженности гидов
+- Эффективное планирование расписания
+- Быстрое выявление свободных дней
+- Простое управление выходными
+
+### Для клиентов:
+- Показываются только доступные туры
+- Мгновенное бронирование без ожидания
+- Прозрачная информация о доступности
+- Улучшенный UX поиска
+
+### Для гидов:
+- Четкое отображение рабочих дней
+- Контроль максимальной загрузки
+- Возможность планировать выходные
+
+## 🔧 Техническая реализация:
+
+- **Frontend**: Интерактивный календарь на vanilla JavaScript
+- **Backend**: API с проверкой доступности в реальном времени
+- **База данных**: Связь расписаний, выходных и бронирований
+- **Интеграция**: Встроен в AdminJS как пользовательская страница
+
+Система полностью готова к использованию и автоматически учитывает все ограничения при поиске и бронировании туров.
\ No newline at end of file
diff --git a/config/styles.json b/config/styles.json
new file mode 100644
index 0000000..4c30e52
--- /dev/null
+++ b/config/styles.json
@@ -0,0 +1,6 @@
+{
+ "primary-color": "#ff6b6b",
+ "secondary-color": "#38C172",
+ "background-color": "#f8f9fa",
+ "text-color": "#333333"
+}
\ No newline at end of file
diff --git a/database/guide-schedules-migration.sql b/database/guide-schedules-migration.sql
new file mode 100644
index 0000000..3a16782
--- /dev/null
+++ b/database/guide-schedules-migration.sql
@@ -0,0 +1,22 @@
+-- Создание таблицы расписания работы гидов
+CREATE TABLE IF NOT EXISTS guide_schedules (
+ id SERIAL PRIMARY KEY,
+ guide_id INTEGER NOT NULL REFERENCES guides(id) ON DELETE CASCADE,
+ work_date DATE NOT NULL,
+ notes TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ -- Уникальный индекс для предотвращения дублирования
+ UNIQUE(guide_id, work_date)
+);
+
+-- Индексы для оптимизации запросов
+CREATE INDEX IF NOT EXISTS idx_guide_schedules_guide_id ON guide_schedules(guide_id);
+CREATE INDEX IF NOT EXISTS idx_guide_schedules_work_date ON guide_schedules(work_date);
+CREATE INDEX IF NOT EXISTS idx_guide_schedules_date_range ON guide_schedules(guide_id, work_date);
+
+-- Комментарии
+COMMENT ON TABLE guide_schedules IS 'Расписание рабочих дней гидов';
+COMMENT ON COLUMN guide_schedules.guide_id IS 'ID гида';
+COMMENT ON COLUMN guide_schedules.work_date IS 'Дата рабочего дня';
+COMMENT ON COLUMN guide_schedules.notes IS 'Дополнительные заметки о рабочем дне';
\ No newline at end of file
diff --git a/database/guide-working-days-migration.sql b/database/guide-working-days-migration.sql
new file mode 100644
index 0000000..5dfa388
--- /dev/null
+++ b/database/guide-working-days-migration.sql
@@ -0,0 +1,22 @@
+-- Создание новой таблицы для конкретных рабочих дней гидов
+CREATE TABLE IF NOT EXISTS guide_working_days (
+ id SERIAL PRIMARY KEY,
+ guide_id INTEGER NOT NULL REFERENCES guides(id) ON DELETE CASCADE,
+ work_date DATE NOT NULL,
+ notes TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ -- Уникальный индекс для предотвращения дублирования
+ UNIQUE(guide_id, work_date)
+);
+
+-- Индексы для оптимизации запросов
+CREATE INDEX IF NOT EXISTS idx_guide_working_days_guide_id ON guide_working_days(guide_id);
+CREATE INDEX IF NOT EXISTS idx_guide_working_days_work_date ON guide_working_days(work_date);
+CREATE INDEX IF NOT EXISTS idx_guide_working_days_date_range ON guide_working_days(guide_id, work_date);
+
+-- Комментарии
+COMMENT ON TABLE guide_working_days IS 'Конкретные рабочие дни гидов';
+COMMENT ON COLUMN guide_working_days.guide_id IS 'ID гида';
+COMMENT ON COLUMN guide_working_days.work_date IS 'Дата рабочего дня';
+COMMENT ON COLUMN guide_working_days.notes IS 'Дополнительные заметки о рабочем дне';
\ No newline at end of file
diff --git a/database/init-database.js b/database/init-database.js
index 1d12c30..faee237 100644
--- a/database/init-database.js
+++ b/database/init-database.js
@@ -15,11 +15,55 @@ export async function initDatabase() {
await db.query('SELECT 1');
console.log('✅ Database connection successful');
- // 1. Create schema
+ // 1. Create schema with trigger safety
console.log('📋 Creating database schema...');
+
+ // Сначала создаем или заменяем функцию
+ await db.query(`
+ CREATE OR REPLACE FUNCTION update_updated_at_column()
+ RETURNS TRIGGER AS $$
+ BEGIN
+ NEW.updated_at = CURRENT_TIMESTAMP;
+ RETURN NEW;
+ END;
+ $$ language 'plpgsql';
+ `);
+
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf8');
await db.query(schema);
+
+ // Проверяем и создаем триггеры только если не существуют
+ const existingTriggers = await db.query(`
+ SELECT trigger_name
+ FROM information_schema.triggers
+ WHERE event_object_schema = 'public'
+ AND trigger_name LIKE '%update%updated_at%'
+ `);
+
+ const triggerNames = new Set(existingTriggers.rows.map(row => row.trigger_name));
+
+ const triggersToCreate = [
+ { table: 'admins', name: 'update_admins_updated_at' },
+ { table: 'routes', name: 'update_routes_updated_at' },
+ { table: 'articles', name: 'update_articles_updated_at' },
+ { table: 'guides', name: 'update_guides_updated_at' }
+ ];
+
+ for (const { table, name } of triggersToCreate) {
+ if (!triggerNames.has(name)) {
+ await db.query(`
+ CREATE TRIGGER ${name}
+ BEFORE UPDATE ON ${table}
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+ `);
+ console.log(`✅ Created trigger ${name}`);
+ } else {
+ console.log(`ℹ️ Trigger ${name} already exists`);
+ }
+ }
+
console.log('✅ Database schema created successfully');
// 2. Check if tables are empty (first run)
diff --git a/docs/SCHEDULE_MANAGER.md b/docs/SCHEDULE_MANAGER.md
new file mode 100644
index 0000000..283f4ed
--- /dev/null
+++ b/docs/SCHEDULE_MANAGER.md
@@ -0,0 +1,117 @@
+# Планировщик рабочих смен гидов
+
+Новый инструмент для управления расписанием работы гидов в туристическом агентстве.
+
+## 🚀 Основные возможности
+
+### ✅ Выбор гидов
+- Множественный выбор гидов чекбоксами
+- Кнопки "Выбрать всех" / "Очистить выбор"
+- Отображение специализации каждого гида
+
+### 📅 Планирование смен
+- Календарный интерфейс на месяц
+- Клик по дню для добавления/удаления смены
+- Визуальные индикаторы занятости
+- Поддержка множественного выбора гидов
+
+### ⚡ Быстрые действия
+- **Отметить будни** - автоматическое планирование пн-пт
+- **Отметить выходные** - планирование суббота-воскресенье
+- **Весь месяц** - отметить все дни месяца
+- **Очистить месяц** - удалить все смены месяца
+
+### 🔄 Копирование между месяцами
+- **Скопировать из прошлого месяца** - копирует структуру предыдущего месяца
+- **Скопировать в следующий месяц** - применяет текущее расписание на следующий месяц
+
+## 🎯 Как использовать
+
+### 1. Доступ к планировщику
+- Войти в админку: `/admin`
+- Перейти в "Планировщик смен" на главной панели
+- Или напрямую: `/admin/schedule-manager`
+
+### 2. Выбор гидов
+1. В левой панели выбрать нужных гидов чекбоксами
+2. Использовать быстрые кнопки для выбора всех/очистки
+
+### 3. Планирование смен
+1. В календаре кликнуть на нужную дату
+2. Если выбрано несколько гидов - смена добавится для всех
+3. Повторный клик уберет смену
+
+### 4. Быстрое планирование
+- Кнопки быстрых действий применяются ко всем выбранным гидам
+- "Отметить будни" - только пн-пт
+- "Отметить выходные" - сб-вс
+- "Весь месяц" - все дни
+
+### 5. Копирование расписания
+- "Скопировать из прошлого месяца" - берет структуру предыдущего месяца
+- "Скопировать в следующий месяц" - сохраняет и копирует в следующий
+
+### 6. Сохранение изменений
+- Кнопка "Сохранить изменения" сохраняет все планы на месяц
+- Автоматическое сохранение при копировании между месяцами
+
+## 📊 Статистика
+
+В нижней части отображается:
+- **Всего гидов** - общее количество гидов
+- **Активных гидов** - количество гидов с назначенными сменами
+- **Ср. дней/гид** - среднее количество рабочих дней на гида
+- **Покрытие месяца** - процент заполненности календаря
+
+## 💡 Советы по использованию
+
+### Эффективное планирование:
+1. Сначала выберите гидов с похожей специализацией
+2. Используйте быстрые действия для базового планирования
+3. Затем корректируйте индивидуальные дни вручную
+
+### Навигация:
+- Стрелки в заголовке календаря для перехода между месяцами
+- Цветовые индикаторы показывают загруженность дней
+
+### Визуальные подсказки:
+- 🟢 **Зеленый день** - все выбранные гиды работают
+- 🟡 **Желто-зеленый** - часть выбранных гидов работает
+- ⚪ **Белый день** - никто из выбранных не работает
+- 🟨 **Желтый день** - выходной день
+
+## 🔧 Техническая информация
+
+### API Endpoints:
+- `GET /api/guide-schedules` - получение расписания
+- `PUT /api/guide-schedules` - сохранение месячного расписания
+- `POST /api/guide-schedules/batch` - массовое добавление
+
+### Структура данных:
+```json
+{
+ "guide_id": 1,
+ "work_date": "2025-12-01"
+}
+```
+
+### База данных:
+- Таблица: `guide_working_days`
+- Уникальный индекс по (guide_id, work_date)
+- Связь с таблицей guides через foreign key
+
+## 🐛 Устранение неполадок
+
+### Проблемы с сохранением:
+1. Проверить соединение с базой данных
+2. Убедиться что выбраны корректные даты
+3. Проверить консоль браузера на ошибки
+
+### Проблемы с отображением:
+1. Перезагрузить страницу
+2. Очистить кеш браузера
+3. Проверить что все компоненты загружены
+
+### Проблемы с авторизацией:
+- Административная сессия продлена до 7 дней
+- При проблемах с доступом перелогиниться в админке
\ No newline at end of file
diff --git a/public/admin-calendar-component.html b/public/admin-calendar-component.html
new file mode 100644
index 0000000..07e3da0
--- /dev/null
+++ b/public/admin-calendar-component.html
@@ -0,0 +1,462 @@
+
+
+
+
+
+ Календарь гидов
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/admin-calendar-full.html b/public/admin-calendar-full.html
new file mode 100644
index 0000000..c84841e
--- /dev/null
+++ b/public/admin-calendar-full.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+ Календарь управления гидами
+
+
+
+
+
+
+
+
+
+ Управление календарем гидов
+
+ Управляйте расписанием и доступностью гидов
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/components/admin-calendar-resource.jsx b/public/components/admin-calendar-resource.jsx
new file mode 100644
index 0000000..dbcb1fc
--- /dev/null
+++ b/public/components/admin-calendar-resource.jsx
@@ -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 (
+
+
Загрузка календаря...
+
+ )
+ }
+
+ return (
+
+
+
Календарь рабочих дней гидов
+
+
+ Выберите гида:
+ setSelectedGuide(e.target.value)}
+ style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ddd', minWidth: '200px' }}
+ >
+ -- Выберите гида --
+ {guides.map(guide => (
+ {guide.name}
+ ))}
+
+
+
+
+ changeMonth(-1)}
+ style={{ padding: '8px 16px', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
+ >
+ ← Предыдущий
+
+
+ {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
+
+ changeMonth(1)}
+ style={{ padding: '8px 16px', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
+ >
+ Следующий →
+
+
+
+
+ {selectedGuide && (
+
+
+ {weekDays.map(day => (
+
+ {day}
+
+ ))}
+
+
+
+ {getDaysInMonth(currentDate).map((day, index) => (
+
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 || ''}
+
+ ))}
+
+
+
+
+ )}
+
+ {!selectedGuide && (
+
+ Выберите гида для просмотра календаря
+
+ )}
+
+ )
+}
+
+export default AdminCalendarResource
\ No newline at end of file
diff --git a/public/components/availability-checker.js b/public/components/availability-checker.js
new file mode 100644
index 0000000..67144d7
--- /dev/null
+++ b/public/components/availability-checker.js
@@ -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 = `
+
+ ${this.mode === 'detailed' ? `
+
+ ` : ''}
+
+
+
+
+
+ ${this.showSuggestions ? `
+
+
Альтернативные варианты:
+
+
+ ` : ''}
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = `
+
+
❌
+
Нет доступных гидов на выбранную дату
+
+ `;
+ } else {
+ resultsContent.innerHTML = `
+
+
✅
+
Доступно ${availableGuides.length} гидов
+
+ ${availableGuides.map(guide => `
+
+
+
${guide.name}
+
${guide.specialization || 'Универсальный'}
+
+
${guide.hourly_rate ? guide.hourly_rate + '₩/час' : 'По договоренности'}
+
+ `).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;
+}
\ No newline at end of file
diff --git a/public/components/guide-calendar-view.jsx b/public/components/guide-calendar-view.jsx
new file mode 100644
index 0000000..006dc5c
--- /dev/null
+++ b/public/components/guide-calendar-view.jsx
@@ -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 (
+
+ Загрузка календаря...
+
+ );
+ }
+
+ return (
+
+ {/* Заголовок и статистика */}
+
+
📅 Календарь рабочих дней гидов
+
+
+ {stats.totalDays} рабочих дней
+
+
+ {stats.totalGuides} активных гидов
+
+
+
+
+ {/* Фильтр по гиду */}
+
+ Фильтр по гиду:
+ setSelectedGuide(e.target.value)}
+ style={{
+ padding: '8px 12px',
+ borderRadius: '6px',
+ border: '1px solid #ddd',
+ minWidth: '200px',
+ fontSize: '14px'
+ }}
+ >
+ Все гиды
+ {guides.map(guide => (
+
+ {guide.name} ({guide.specialization})
+
+ ))}
+
+ {selectedGuide && (
+ setSelectedGuide('')}
+ style={{
+ padding: '6px 12px',
+ background: '#ff5722',
+ color: 'white',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '12px'
+ }}
+ >
+ Очистить
+
+ )}
+
+
+ {/* Навигация по месяцам */}
+
+ changeMonth(-1)}
+ style={{
+ padding: '10px 20px',
+ border: '1px solid #ddd',
+ borderRadius: '6px',
+ background: 'white',
+ cursor: 'pointer',
+ fontSize: '14px',
+ fontWeight: 'bold'
+ }}
+ >
+ ← Предыдущий
+
+
+ {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
+
+ changeMonth(1)}
+ style={{
+ padding: '10px 20px',
+ border: '1px solid #ddd',
+ borderRadius: '6px',
+ background: 'white',
+ cursor: 'pointer',
+ fontSize: '14px',
+ fontWeight: 'bold'
+ }}
+ >
+ Следующий →
+
+
+
+ {/* Календарная сетка */}
+
+ {/* Заголовки дней недели */}
+
+ {weekDays.map(day => (
+
+ {day}
+
+ ))}
+
+
+ {/* Дни месяца */}
+
+ {getDaysInMonth(currentDate).map((day, index) => {
+ const dayWorkingData = getWorkingDaysForDate(day);
+ const hasData = dayWorkingData.length > 0;
+
+ return (
+
+ {day && (
+ <>
+
+ {day}
+
+
+ {dayWorkingData.map((workDay, idx) => {
+ const guide = getGuideById(workDay.guide_id);
+ return (
+
+
+ {guide?.name || `Гид #${workDay.guide_id}`}
+
+ {workDay.notes && (
+
+ {workDay.notes.length > 20 ? workDay.notes.substring(0, 20) + '...' : workDay.notes}
+
+ )}
+
+ );
+ })}
+ >
+ )}
+
+ );
+ })}
+
+
+
+ {/* Легенда */}
+
+
+ {/* Быстрые действия */}
+
+
Быстрые действия:
+
+ window.open('/admin/calendar-view', '_blank')}
+ style={{
+ padding: '8px 16px',
+ background: '#2196f3',
+ color: 'white',
+ border: 'none',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ fontSize: '14px'
+ }}
+ >
+ 📅 Полный календарь
+
+ window.open('/admin/schedule-manager', '_blank')}
+ style={{
+ padding: '8px 16px',
+ background: '#4caf50',
+ color: 'white',
+ border: 'none',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ fontSize: '14px'
+ }}
+ >
+ ⚡ Планировщик смен
+
+ loadWorkingDays()}
+ style={{
+ padding: '8px 16px',
+ background: '#ff9800',
+ color: 'white',
+ border: 'none',
+ borderRadius: '6px',
+ cursor: 'pointer',
+ fontSize: '14px'
+ }}
+ >
+ 🔄 Обновить
+
+
+
+
+ );
+};
+
+export default GuideCalendarView;
\ No newline at end of file
diff --git a/public/components/guide-calendar-widget.js b/public/components/guide-calendar-widget.js
new file mode 100644
index 0000000..64bf890
--- /dev/null
+++ b/public/components/guide-calendar-widget.js
@@ -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 = `
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = `${message}
`;
+ }
+ }
+
+ 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 = 'Нет доступных гидов
';
+ 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 = `
+
+ ${guide.name.split(' ')[0]}
+ `;
+
+ 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 += `${guide.name.split(' ')[0]}
`;
+ });
+
+ return `
+ ${dayNumber}
+ ${guideStatusHtml}
+ `;
+ }
+
+ 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;
+}
\ No newline at end of file
diff --git a/public/components/guide-schedule-manager.js b/public/components/guide-schedule-manager.js
new file mode 100644
index 0000000..4938e42
--- /dev/null
+++ b/public/components/guide-schedule-manager.js
@@ -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 = `
+
+
+
+
+
+
+
+
+
+
+
+
+ Выбрать всех
+
+
+ Очистить выбор
+
+
+
+
+
+
+
+
+
+
+
+
+ Отметить будни
+
+
+ Отметить выходные
+
+
+ Весь месяц
+
+
+ Очистить месяц
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ this.injectStyles();
+ }
+
+ injectStyles() {
+ if (document.getElementById('schedule-manager-styles')) return;
+
+ const styles = `
+
+ `;
+
+ 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 => `
+
+
+
+
+
${guide.name}
+
${guide.specialization || 'Универсальный'}
+
+
+
+ `).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 = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${weeks.map(week => `
+
+ ${week.map(date => this.renderCalendarDay(date, month)).join('')}
+
+ `).join('')}
+
+
+ `;
+
+ 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 ? `
+
+ ${workingGuides.slice(0, 5).map(guide => `
+ ${guide.name.charAt(0)}
+ `).join('')}
+ ${workingGuides.length > 5 ? `+${workingGuides.length - 5} ` : ''}
+
+ ` : '';
+
+ return `
+
+ ${date.getDate()}
+ ${workingGuidesHTML}
+
+ `;
+ }
+
+ 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 = `
+
+
+
${stats.totalGuides}
+
Всего гидов
+
+
+
+
+
${stats.activeGuides}
+
Активных гидов
+
+
+
+
+
${stats.averageWorkingDays}
+
Ср. дней/гид
+
+
+
+
+
${coverage}%
+
Покрытие месяца
+
+
+ `;
+ }
+}
+
+if (typeof window !== 'undefined') {
+ window.GuideScheduleManager = GuideScheduleManager;
+}
\ No newline at end of file
diff --git a/public/components/guide-selector.js b/public/components/guide-selector.js
new file mode 100644
index 0000000..55515c8
--- /dev/null
+++ b/public/components/guide-selector.js
@@ -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 = `
+
+ ${this.mode === 'booking' ? `
+
+ ` : ''}
+
+
+
+
+
+ ${this.multiple ? `
+
+ ` : ''}
+
+ `;
+
+ 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 = `
+
+ `;
+
+ 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 = 'Нет доступных гидов
';
+ 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 = `
+
+ ${this.selectedDate ?
+ 'Нет доступных гидов на выбранную дату. Попробуйте другую дату.' :
+ 'Нет доступных гидов'
+ }
+
+ `;
+ 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 `
+
+
+
+ ${this.mode !== 'simple' ? `
+
+
+ Опыт: ${guide.experience || 'Не указан'} лет
+
+
+ Языки: ${guide.languages || 'Не указаны'}
+
+
+ Email: ${guide.email || 'Не указан'}
+
+
+ ${guide.hourly_rate ? `${guide.hourly_rate}₩/час` : 'Цена договорная'}
+
+
+ ` : ''}
+
+ `;
+ }
+
+ 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 `
+
+ ${guide.name}
+ ×
+
+ `;
+ }).join('');
+ }
+
+ showError(message) {
+ const listContainer = this.container.querySelector(`#guidesList-${this.getId()}`);
+ if (listContainer) {
+ listContainer.innerHTML = `${message}
`;
+ }
+ }
+
+ // Публичные методы
+ 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;
+}
\ No newline at end of file
diff --git a/public/css/admin-custom.css b/public/css/admin-custom.css
index 150bc2b..b05fd2b 100644
--- a/public/css/admin-custom.css
+++ b/public/css/admin-custom.css
@@ -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;
}
\ No newline at end of file
diff --git a/public/css/custom-styles.css b/public/css/custom-styles.css
new file mode 100644
index 0000000..4b35007
--- /dev/null
+++ b/public/css/custom-styles.css
@@ -0,0 +1,3 @@
+/* Автоматически сгенерированные стили - 2025-11-30T02:42:19.565Z */
+
+:root { --primary-color: #ff6b6b; --secondary-color: #38C172; }
\ No newline at end of file
diff --git a/public/guide-calendar.html b/public/guide-calendar.html
new file mode 100644
index 0000000..1307c89
--- /dev/null
+++ b/public/guide-calendar.html
@@ -0,0 +1,557 @@
+
+
+
+
+
+ Календарь гидов
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/image-manager.html b/public/image-manager.html
new file mode 100644
index 0000000..f84a4a0
--- /dev/null
+++ b/public/image-manager.html
@@ -0,0 +1,953 @@
+
+
+
+
+
+ Менеджер изображений
+
+
+
+
+
+
+
+
+
+
📸
+
Перетащите изображения сюда или нажмите для выбора
+
Поддерживаются JPG, PNG, GIF, WEBP до 10MB
+
+
+
+
+
+
+
+
+
+
+ 🔍
+
+
+
+
+ Все типы
+ Маршруты
+ Гиды
+ Статьи
+ Общие
+
+
+
+ ⊞
+ ≡
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Имя файла:
+
Размер:
+
URL:
+
+
+ 📋 Копировать URL
+ 💾 Скачать
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/images/logo_dark.png b/public/images/logo_dark.png
new file mode 100644
index 0000000..6b65081
Binary files /dev/null and b/public/images/logo_dark.png differ
diff --git a/public/images/logo_white.png b/public/images/logo_white.png
new file mode 100644
index 0000000..ba7e59e
Binary files /dev/null and b/public/images/logo_white.png differ
diff --git a/public/js/admin-image-selector-fixed.js b/public/js/admin-image-selector-fixed.js
index 9e39b5a..bd1420a 100644
--- a/public/js/admin-image-selector-fixed.js
+++ b/public/js/admin-image-selector-fixed.js
@@ -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;
}
// Функция сканирования и добавления кнопок к полям изображений
diff --git a/public/js/main.js b/public/js/main.js
index 2e9fbfa..872f585 100644
--- a/public/js/main.js
+++ b/public/js/main.js
@@ -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 = `
+
+ `;
+
+ 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! 🇰🇷');
});
\ No newline at end of file
diff --git a/public/js/universal-media-manager-integration.js b/public/js/universal-media-manager-integration.js
new file mode 100644
index 0000000..5e9f5e9
--- /dev/null
+++ b/public/js/universal-media-manager-integration.js
@@ -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 = `
+
+
+
+
+
+ `;
+
+ // 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();
+
+})();
\ No newline at end of file
diff --git a/public/professional-style-editor.html b/public/professional-style-editor.html
new file mode 100644
index 0000000..f97e188
--- /dev/null
+++ b/public/professional-style-editor.html
@@ -0,0 +1,1010 @@
+
+
+
+
+
+ Профессиональный редактор стилей
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/schedule-manager.html b/public/schedule-manager.html
new file mode 100644
index 0000000..e30dc55
--- /dev/null
+++ b/public/schedule-manager.html
@@ -0,0 +1,282 @@
+
+
+
+
+
+ Планировщик рабочих смен - Корея Тур Агентство
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Загрузка...
+
+
+
Загрузка планировщика смен...
+
Подождите, пожалуйста
+
+
+
+
+
+
+
+
+
Ошибка загрузки
+
Не удалось загрузить планировщик смен. Попробуйте перезагрузить страницу.
+
+ Перезагрузить
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/style-editor-advanced.html b/public/style-editor-advanced.html
new file mode 100644
index 0000000..c2e0d4d
--- /dev/null
+++ b/public/style-editor-advanced.html
@@ -0,0 +1,1013 @@
+
+
+
+
+
+ Редактор стилей сайта
+
+
+
+ ☰
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/test-image-editor-debug.html b/public/test-image-editor-debug.html
new file mode 100644
index 0000000..3ff9299
--- /dev/null
+++ b/public/test-image-editor-debug.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+ Тест редактора изображений
+
+
+
+ 🧪 Тест редактора изображений
+ Эта страница тестирует интеграцию редактора изображений с AdminJS
+
+
+
+ URL изображения маршрута:
+
+
+
+
+ Изображение профиля:
+
+
+
+
+ Изображение статьи:
+
+
+
+
+ Обычное поле (заголовок):
+
+
+
+
+ Описание:
+
+
+
+
+
+
📊 Результаты теста:
+
+
⏳ Загрузка и инициализация скрипта...
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/tours-calendar.html b/public/tours-calendar.html
new file mode 100644
index 0000000..8838242
--- /dev/null
+++ b/public/tours-calendar.html
@@ -0,0 +1,625 @@
+
+
+
+
+
+ Календарь туров - Korea Tourism
+
+
+
+
+
+
+
+
← Предыдущий месяц
+
Загрузка...
+
Следующий месяц →
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/public/universal-media-manager.html b/public/universal-media-manager.html
new file mode 100644
index 0000000..001b8a6
--- /dev/null
+++ b/public/universal-media-manager.html
@@ -0,0 +1,909 @@
+
+
+
+
+
+ Универсальный медиа-менеджер
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app.js b/src/app.js
index df65d48..e7cb2b8 100644
--- a/src/app.js
+++ b/src/app.js
@@ -161,6 +161,10 @@ const settingsRouter = (await import('./routes/settings.js')).default;
const ratingsRouter = (await import('./routes/ratings.js')).default;
const imagesRouter = (await import('./routes/images.js')).default;
const crudRouter = (await import('./routes/crud.js')).default;
+const testRouter = (await import('./routes/test.js')).default;
+const adminToolsRouter = (await import('./routes/admin-tools.js')).default;
+const adminCalendarRouter = (await import('./routes/admin-calendar.js')).default;
+const guideSchedulesRouter = (await import('./routes/guide-schedules.js')).default;
app.use('/', indexRouter);
app.use('/routes', toursRouter);
@@ -171,6 +175,10 @@ app.use('/api', ratingsRouter);
app.use('/', settingsRouter); // Settings routes (CSS and API)
app.use('/api/images', imagesRouter); // Image management routes
app.use('/api/crud', crudRouter); // CRUD API routes
+app.use('/api', testRouter); // Test routes
+app.use('/', adminToolsRouter); // Admin tools routes
+app.use('/admin', adminCalendarRouter); // Admin calendar routes
+app.use('/api/guide-schedules', guideSchedulesRouter); // Guide schedules API
// Health check endpoint
app.get('/health', (req, res) => {
diff --git a/src/components/admin-calendar-page.js b/src/components/admin-calendar-page.js
new file mode 100644
index 0000000..4311cd8
--- /dev/null
+++ b/src/components/admin-calendar-page.js
@@ -0,0 +1,490 @@
+const AdminCalendarPage = {
+ resource: 'calendar',
+ options: {
+ navigation: {
+ name: '📅 Календарь гидов',
+ icon: 'Calendar'
+ },
+ parent: { name: 'Управление гидами', icon: 'Calendar' },
+ actions: {
+ list: {
+ isVisible: true,
+ component: false,
+ handler: async (request, response, context) => {
+ const html = `
+
+
+
+
+
+ Календарь гидов
+
+
+
+
+
+
📅 Календарь рабочих дней гидов
+
+
+
+
+ Выберите гида:
+
+ -- Выберите гида --
+
+
+
+
Быстрые действия:
+
+ Рабочие дни
+ Выходные
+ Весь месяц
+ Очистить
+
+
+
+
+
+
+
+
+
+
+
+ ПН
+ ВТ
+ СР
+ ЧТ
+ ПТ
+ СБ
+ ВС
+
+
+
+
+
+
+
+
+
+
+
+
+ Загрузка...
+
+
Обновление календаря...
+
+
+
+
+
+
+
+`;
+
+ response.send(html);
+ }
+ },
+ new: { isVisible: false },
+ edit: { isVisible: false },
+ delete: { isVisible: false },
+ show: { isVisible: false }
+ }
+ }
+};
+
+export default AdminCalendarPage;
\ No newline at end of file
diff --git a/src/components/index.js b/src/components/index.js
new file mode 100644
index 0000000..cbf259d
--- /dev/null
+++ b/src/components/index.js
@@ -0,0 +1,21 @@
+import AdminJS from 'adminjs';
+
+// Компонент для редактора стилей в AdminJS
+const StyleEditorComponent = {
+ component: AdminJS.bundle('./components/style-editor-component.jsx'),
+ props: {
+ title: 'Редактор стилей сайта',
+ description: 'Настройка внешнего вида и темы сайта'
+ }
+};
+
+// Компонент для менеджера изображений в AdminJS
+const ImageManagerComponent = {
+ component: AdminJS.bundle('./components/image-manager-component.jsx'),
+ props: {
+ title: 'Менеджер изображений',
+ description: 'Управление изображениями сайта'
+ }
+};
+
+export { StyleEditorComponent, ImageManagerComponent };
\ No newline at end of file
diff --git a/src/config/adminjs-simple.js b/src/config/adminjs-simple.js
index 5d0668a..612c7cd 100644
--- a/src/config/adminjs-simple.js
+++ b/src/config/adminjs-simple.js
@@ -8,6 +8,7 @@ import { Sequelize, DataTypes } from 'sequelize';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
+import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -143,10 +144,10 @@ const Admins = sequelize.define('admins', {
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false },
password: { type: DataTypes.STRING, allowNull: false },
- role: { type: DataTypes.ENUM('admin', 'manager', 'editor'), defaultValue: 'admin' },
+ role: { type: DataTypes.STRING, defaultValue: 'admin' },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
- last_login: { type: DataTypes.DATE },
- created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'admins'
@@ -196,6 +197,18 @@ const Holidays = sequelize.define('holidays', {
tableName: 'holidays'
});
+// Модель для календаря рабочих дней
+const GuideWorkingDays = sequelize.define('guide_working_days', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ guide_id: { type: DataTypes.INTEGER, allowNull: false },
+ work_date: { type: DataTypes.DATEONLY, allowNull: false },
+ notes: { type: DataTypes.TEXT },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'guide_working_days'
+});
+
// Модель настроек сайта
const SiteSettings = sequelize.define('site_settings', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
@@ -217,6 +230,9 @@ GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
Guides.hasMany(Holidays, { foreignKey: 'guide_id' });
Holidays.belongsTo(Guides, { foreignKey: 'guide_id' });
+Guides.hasMany(GuideWorkingDays, { foreignKey: 'guide_id' });
+GuideWorkingDays.belongsTo(Guides, { foreignKey: 'guide_id' });
+
Guides.hasMany(Bookings, { foreignKey: 'guide_id' });
Bookings.belongsTo(Guides, { foreignKey: 'guide_id' });
@@ -239,11 +255,62 @@ const getRatingStats = async (targetType, targetId) => {
// Конфигурация AdminJS с ресурсами базы данных
// Конфигурация AdminJS с ресурсами Sequelize
const adminJsOptions = {
+ locale: {
+ language: 'ru',
+ availableLanguages: ['ru', 'en'],
+ translations: {
+ en: {
+ labels: {
+ routes: 'Routes',
+ 'routes': 'Routes',
+ '🗺️ Маршруты': 'Routes',
+ guides: 'Guides',
+ 'guides': 'Guides',
+ '👥 Гиды': 'Guides',
+ articles: 'Articles',
+ 'articles': 'Articles',
+ '📰 Статьи': 'Articles',
+ bookings: 'Bookings',
+ 'bookings': 'Bookings',
+ 'Заказы': 'Bookings',
+ reviews: 'Reviews',
+ 'reviews': 'Reviews',
+ '⭐ Отзывы': 'Reviews',
+ contact_messages: 'Contact Messages',
+ 'contact_messages': 'Contact Messages',
+ '💌 Сообщения': 'Contact Messages',
+ admins: 'Admins',
+ 'admins': 'Admins',
+ '👨💼 Администраторы': 'Admins',
+ ratings: 'Ratings',
+ 'ratings': 'Ratings',
+ '🌟 Рейтинги': 'Ratings',
+ guide_schedules: 'Guide Schedules',
+ 'guide_schedules': 'Guide Schedules',
+ '📅 Расписание гидов': 'Guide Schedules',
+ '📅 Расписание Гидов': 'Guide Schedules',
+ holidays: 'Holidays',
+ 'holidays': 'Holidays',
+ '🏖️ Выходные дни': 'Holidays',
+ '🏖️ Выходные Дни': 'Holidays',
+ site_settings: 'Site Settings',
+ 'site_settings': 'Site Settings',
+ '⚙️ Настройки сайта': 'Site Settings',
+ '⚙️ Настройки Сайта': 'Site Settings'
+ },
+ pages: {
+ calendar: 'Calendar',
+ 'style-editor': 'Style Editor',
+ 'media-manager': 'Media Manager'
+ }
+ }
+ }
+ },
resources: [
{
resource: Routes,
options: {
- parent: { name: 'Контент', icon: 'DocumentText' },
+ parent: { name: 'Контент сайта', icon: 'DocumentText' },
listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'],
editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active'],
showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active', 'created_at', 'updated_at'],
@@ -304,7 +371,8 @@ const adminJsOptions = {
{
resource: Guides,
options: {
- parent: { name: 'Персонал', icon: 'Users' },
+ parent: { name: 'Персонал и гиды', icon: 'Users' },
+ id: 'guides',
listProperties: ['id', 'name', 'email', 'specialization', 'experience', 'hourly_rate', 'is_active'],
editProperties: ['name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'image_url', 'hourly_rate', 'is_active'],
showProperties: ['id', 'name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'image_url', 'hourly_rate', 'is_active', 'created_at'],
@@ -354,7 +422,7 @@ const adminJsOptions = {
{
resource: Articles,
options: {
- parent: { name: 'Контент', icon: 'DocumentText' },
+ parent: { name: 'Контент сайта', icon: 'DocumentText' },
listProperties: ['id', 'title', 'category', 'is_published', 'views', 'created_at'],
editProperties: ['title', 'excerpt', 'content', 'category', 'image_url', 'is_published'],
showProperties: ['id', 'title', 'excerpt', 'content', 'category', 'is_published', 'views', 'created_at', 'updated_at'],
@@ -402,7 +470,7 @@ const adminJsOptions = {
{
resource: Bookings,
options: {
- parent: { name: 'Заказы', icon: 'ShoppingCart' },
+ parent: { name: 'Бронирования', icon: 'ShoppingCart' },
listProperties: ['id', 'customer_name', 'customer_email', 'preferred_date', 'status', 'total_price', 'created_at'],
editProperties: ['customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes'],
showProperties: ['id', 'customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes', 'created_at'],
@@ -447,7 +515,7 @@ const adminJsOptions = {
{
resource: Reviews,
options: {
- parent: { name: 'Отзывы', icon: 'Star' },
+ parent: { name: 'Отзывы и рейтинги', icon: 'Star' },
listProperties: ['id', 'customer_name', 'rating', 'is_approved', 'created_at'],
editProperties: ['customer_name', 'customer_email', 'rating', 'comment', 'is_approved'],
showProperties: ['id', 'customer_name', 'customer_email', 'rating', 'comment', 'is_approved', 'created_at'],
@@ -479,7 +547,7 @@ const adminJsOptions = {
{
resource: ContactMessages,
options: {
- parent: { name: 'Сообщения', icon: 'Email' },
+ parent: { name: 'Администрирование', icon: 'Settings' },
listProperties: ['id', 'name', 'email', 'subject', 'status', 'created_at'],
editProperties: ['name', 'email', 'phone', 'subject', 'message', 'status'],
showProperties: ['id', 'name', 'email', 'phone', 'subject', 'message', 'status', 'created_at'],
@@ -526,6 +594,7 @@ const adminJsOptions = {
resource: Admins,
options: {
parent: { name: 'Администрирование', icon: 'Settings' },
+ id: 'admins',
listProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'created_at'],
editProperties: ['username', 'name', 'email', 'role', 'is_active'],
showProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'last_login', 'created_at'],
@@ -567,7 +636,7 @@ const adminJsOptions = {
{
resource: Ratings,
options: {
- parent: { name: 'Система рейтингов', icon: 'Star' },
+ parent: { name: 'Отзывы и рейтинги', icon: 'Star' },
listProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
showProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
filterProperties: ['target_type', 'target_id', 'rating'],
@@ -601,7 +670,7 @@ const adminJsOptions = {
{
resource: GuideSchedules,
options: {
- parent: { name: 'Управление гидами', icon: 'Calendar' },
+ parent: { name: 'Персонал и гиды', icon: 'Users' },
listProperties: ['id', 'guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time'],
editProperties: ['guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time'],
showProperties: ['id', 'guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time', 'created_at', 'updated_at'],
@@ -633,12 +702,42 @@ const adminJsOptions = {
isVisible: { list: false, filter: false, show: true, edit: false },
}
},
+ actions: {
+ list: {
+ after: async (response) => {
+ if (response.records) {
+ response.meta = {
+ ...response.meta,
+ customHeader: `
+
+
+
+
📅 Календарь расписания гидов
+
Визуализация расписания работы, выходных дней и загруженности всех гидов
+
+
+
+
+ `
+ };
+ }
+ return response;
+ }
+ },
+ new: { isVisible: true },
+ edit: { isVisible: true },
+ delete: { isVisible: true },
+ show: { isVisible: true }
+ }
}
},
{
resource: Holidays,
options: {
- parent: { name: 'Управление гидами', icon: 'Calendar' },
+ parent: { name: 'Персонал и гиды', icon: 'Users' },
listProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
editProperties: ['date', 'title', 'type', 'guide_id'],
showProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
@@ -669,10 +768,61 @@ const adminJsOptions = {
},
}
},
+ {
+ resource: GuideWorkingDays,
+ options: {
+ parent: { name: 'Персонал и гиды', icon: 'Users' },
+ listProperties: ['id', 'guide_id', 'work_date', 'notes', 'created_at'],
+ editProperties: ['guide_id', 'work_date', 'notes'],
+ showProperties: ['id', 'guide_id', 'work_date', 'notes', 'created_at'],
+ filterProperties: ['guide_id', 'work_date'],
+ properties: {
+ guide_id: {
+ isTitle: true,
+ isRequired: true,
+ },
+ work_date: {
+ type: 'date',
+ isRequired: true,
+ },
+ notes: {
+ type: 'textarea',
+ description: 'Дополнительные заметки для этого рабочего дня'
+ },
+ created_at: {
+ isVisible: { list: true, filter: false, show: true, edit: false },
+ }
+ },
+ actions: {
+ list: {
+ isVisible: true,
+ after: async (response) => {
+ // Добавляем информацию о календаре
+ if (response.records) {
+ response.meta = response.meta || {};
+ response.meta.total = response.records.length;
+ response.meta.notice = {
+ message: '💡 Для удобного управления расписанием используйте полный календарь',
+ type: 'info'
+ };
+ }
+ return response;
+ }
+ },
+ new: {
+ isVisible: true
+ },
+ edit: { isVisible: true },
+ delete: { isVisible: true },
+ show: { isVisible: true }
+ }
+ }
+ },
{
resource: SiteSettings,
options: {
parent: { name: 'Администрирование', icon: 'Settings' },
+ id: 'site_settings',
listProperties: ['id', 'setting_key', 'setting_value', 'category', 'updated_at'],
editProperties: ['setting_key', 'setting_value', 'setting_type', 'description', 'category'],
showProperties: ['id', 'setting_key', 'setting_value', 'setting_type', 'description', 'category', 'updated_at'],
@@ -745,11 +895,161 @@ const adminJsOptions = {
},
},
dashboard: {
- component: false
+ handler: (req, res) => {
+ const html = `
+
+
+
+
+
+ Админ панель - Главная
+
+
+
+
+
🚀 Админ панель Korea Tourism
+
+
+
Новая функция: Календарь гидов!
+
Теперь доступен календарь работы гидов с отображением расписания, выходных дней и загруженности.
+ Поиск туров учитывает доступность гидов на конкретную дату.
+
+
+
+
+
+
+
+
+
+
+ `;
+ res.send(html);
+ }
+ },
+ actions: {
+ 'style-editor': {
+ actionType: 'record',
+ icon: 'Settings',
+ label: '🎨 Редактор стилей',
+ handler: (request, response, data) => {
+ return {
+ redirectUrl: '/admin/style-editor'
+ }
+ }
+ },
+ 'image-manager': {
+ actionType: 'record',
+ icon: 'Image',
+ label: '🖼️ Менеджер изображений',
+ handler: (request, response, data) => {
+ return {
+ redirectUrl: '/admin/image-manager'
+ }
+ }
+ }
},
assets: {
styles: ['/css/admin-custom.css'],
- scripts: ['/js/admin-image-selector-fixed.js']
+ scripts: [
+ '/js/admin-image-selector-fixed.js',
+ '/js/universal-media-manager-integration.js'
+ ]
}
};
@@ -797,13 +1097,37 @@ const router = AdminJSExpress.buildAuthenticatedRouter(adminJs, {
},
cookiePassword: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024'
}, null, {
- resave: false,
- saveUninitialized: false,
+ resave: true,
+ saveUninitialized: true,
secret: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024',
+ name: 'adminjs-session',
cookie: {
httpOnly: true,
- secure: process.env.NODE_ENV === 'production',
- maxAge: 24 * 60 * 60 * 1000 // 24 часа
+ secure: false, // Отключаем secure для development
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
+ sameSite: 'lax'
+ },
+ rolling: true // Продлеваем сессию при каждом запросе
+});
+
+// Добавляем дополнительные роуты после аутентификации
+router.get('/calendar-view', async (request, response) => {
+ try {
+ const calendarPath = path.join(__dirname, '../../public/admin-calendar-component.html');
+ const calendarHtml = fs.readFileSync(calendarPath, 'utf8');
+ response.send(calendarHtml);
+ } catch (error) {
+ response.status(500).send('Error loading calendar');
+ }
+});
+
+router.get('/schedule-manager', async (request, response) => {
+ try {
+ const schedulePath = path.join(__dirname, '../../public/schedule-manager.html');
+ const scheduleHtml = fs.readFileSync(schedulePath, 'utf8');
+ response.send(scheduleHtml);
+ } catch (error) {
+ response.status(500).send('Error loading schedule manager');
}
});
diff --git a/src/config/adminjs-simple.js.backup b/src/config/adminjs-simple.js.backup
new file mode 100644
index 0000000..0b783e7
--- /dev/null
+++ b/src/config/adminjs-simple.js.backup
@@ -0,0 +1,1681 @@
+import AdminJS from 'adminjs';
+import AdminJSExpress from '@adminjs/express';
+import AdminJSSequelize from '@adminjs/sequelize';
+import uploadFeature from '@adminjs/upload';
+import bcrypt from 'bcryptjs';
+import pkg from 'pg';
+import { Sequelize, DataTypes } from 'sequelize';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { dirname } from 'path';
+import fs from 'fs';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const { Pool } = pkg;
+
+// Регистрируем адаптер Sequelize
+AdminJS.registerAdapter(AdminJSSequelize);
+
+// Создаем подключение Sequelize
+const sequelize = new Sequelize(
+ process.env.DB_NAME || 'korea_tourism',
+ process.env.DB_USER || 'tourism_user',
+ process.env.DB_PASSWORD || 'tourism_password',
+ {
+ host: process.env.DB_HOST || 'postgres',
+ port: process.env.DB_PORT || 5432,
+ dialect: 'postgres',
+ logging: false,
+ }
+);
+
+// Создаем пул подключений для аутентификации (отдельно от Sequelize)
+const authPool = new Pool({
+ host: process.env.DB_HOST || 'postgres',
+ port: process.env.DB_PORT || 5432,
+ database: process.env.DB_NAME || 'korea_tourism',
+ user: process.env.DB_USER || 'tourism_user',
+ password: process.env.DB_PASSWORD || 'tourism_password',
+});
+
+// Определяем модели Sequelize
+const Routes = sequelize.define('routes', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ title: { type: DataTypes.STRING, allowNull: false },
+ description: { type: DataTypes.TEXT },
+ content: { type: DataTypes.TEXT },
+ type: { type: DataTypes.ENUM('city', 'mountain', 'fishing') },
+ difficulty_level: { type: DataTypes.ENUM('easy', 'moderate', 'hard') },
+ price: { type: DataTypes.DECIMAL(10, 2) },
+ duration: { type: DataTypes.INTEGER },
+ max_group_size: { type: DataTypes.INTEGER },
+ image_url: { type: DataTypes.STRING },
+ is_featured: { type: DataTypes.BOOLEAN, defaultValue: false },
+ is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'routes'
+});
+
+const Guides = sequelize.define('guides', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ name: { type: DataTypes.STRING, allowNull: false },
+ email: { type: DataTypes.STRING },
+ phone: { type: DataTypes.STRING },
+ languages: { type: DataTypes.TEXT },
+ specialization: { type: DataTypes.ENUM('city', 'mountain', 'fishing', 'general') },
+ bio: { type: DataTypes.TEXT },
+ experience: { type: DataTypes.INTEGER },
+ image_url: { type: DataTypes.STRING },
+ hourly_rate: { type: DataTypes.DECIMAL(10, 2) },
+ is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'guides'
+});
+
+const Articles = sequelize.define('articles', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ title: { type: DataTypes.STRING, allowNull: false },
+ excerpt: { type: DataTypes.TEXT },
+ content: { type: DataTypes.TEXT, allowNull: false },
+ category: { type: DataTypes.ENUM('travel-tips', 'culture', 'food', 'nature', 'history') },
+ is_published: { type: DataTypes.BOOLEAN, defaultValue: false },
+ views: { type: DataTypes.INTEGER, defaultValue: 0 },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'articles'
+});
+
+const Bookings = sequelize.define('bookings', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ customer_name: { type: DataTypes.STRING, allowNull: false },
+ customer_email: { type: DataTypes.STRING, allowNull: false },
+ customer_phone: { type: DataTypes.STRING },
+ preferred_date: { type: DataTypes.DATE, allowNull: false },
+ group_size: { type: DataTypes.INTEGER, allowNull: false },
+ status: { type: DataTypes.ENUM('pending', 'confirmed', 'cancelled', 'completed'), defaultValue: 'pending' },
+ total_price: { type: DataTypes.DECIMAL(10, 2), allowNull: false },
+ notes: { type: DataTypes.TEXT },
+ guide_id: { type: DataTypes.INTEGER },
+ route_id: { type: DataTypes.INTEGER },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'bookings'
+});
+
+const Reviews = sequelize.define('reviews', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ customer_name: { type: DataTypes.STRING, allowNull: false },
+ customer_email: { type: DataTypes.STRING },
+ rating: { type: DataTypes.INTEGER, validate: { min: 1, max: 5 } },
+ comment: { type: DataTypes.TEXT },
+ is_approved: { type: DataTypes.BOOLEAN, defaultValue: false },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'reviews'
+});
+
+const ContactMessages = sequelize.define('contact_messages', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ name: { type: DataTypes.STRING, allowNull: false },
+ email: { type: DataTypes.STRING, allowNull: false },
+ phone: { type: DataTypes.STRING },
+ subject: { type: DataTypes.STRING, allowNull: false },
+ message: { type: DataTypes.TEXT, allowNull: false },
+ status: { type: DataTypes.ENUM('unread', 'read', 'replied'), defaultValue: 'unread' },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'contact_messages'
+});
+
+const Admins = sequelize.define('admins', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ username: { type: DataTypes.STRING, allowNull: false, unique: true },
+ name: { type: DataTypes.STRING, allowNull: false },
+ email: { type: DataTypes.STRING, allowNull: false },
+ password: { type: DataTypes.STRING, allowNull: false },
+ role: { type: DataTypes.STRING, defaultValue: 'admin' },
+ is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'admins'
+});
+
+// Новые модели для системы рейтинга и расписания
+const Ratings = sequelize.define('ratings', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ user_ip: { type: DataTypes.STRING(45), allowNull: false },
+ target_id: { type: DataTypes.INTEGER, allowNull: false },
+ target_type: { type: DataTypes.ENUM('route', 'guide', 'article'), allowNull: false },
+ rating: { type: DataTypes.INTEGER, allowNull: false, validate: { isIn: [[1, -1]] } },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'ratings'
+});
+
+const GuideSchedules = sequelize.define('guide_schedules', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ guide_id: { type: DataTypes.INTEGER, allowNull: false },
+ monday: { type: DataTypes.BOOLEAN, defaultValue: true },
+ tuesday: { type: DataTypes.BOOLEAN, defaultValue: true },
+ wednesday: { type: DataTypes.BOOLEAN, defaultValue: true },
+ thursday: { type: DataTypes.BOOLEAN, defaultValue: true },
+ friday: { type: DataTypes.BOOLEAN, defaultValue: true },
+ saturday: { type: DataTypes.BOOLEAN, defaultValue: false },
+ sunday: { type: DataTypes.BOOLEAN, defaultValue: false },
+ start_time: { type: DataTypes.TIME, defaultValue: '09:00' },
+ end_time: { type: DataTypes.TIME, defaultValue: '18:00' },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
+ updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'guide_schedules'
+});
+
+const Holidays = sequelize.define('holidays', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ date: { type: DataTypes.DATEONLY, allowNull: false },
+ title: { type: DataTypes.STRING, allowNull: false },
+ type: { type: DataTypes.ENUM('public', 'guide_personal'), allowNull: false },
+ guide_id: { type: DataTypes.INTEGER },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'holidays'
+});
+
+// Модель для календаря рабочих дней
+const GuideWorkingDays = sequelize.define('guide_working_days', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ guide_id: { type: DataTypes.INTEGER, allowNull: false },
+ work_date: { type: DataTypes.DATEONLY, allowNull: false },
+ notes: { type: DataTypes.TEXT },
+ created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'guide_working_days'
+});
+
+// Модель настроек сайта
+const SiteSettings = sequelize.define('site_settings', {
+ id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
+ setting_key: { type: DataTypes.STRING, allowNull: false, unique: true },
+ setting_value: { type: DataTypes.TEXT },
+ setting_type: { type: DataTypes.ENUM('text', 'number', 'boolean', 'json', 'color', 'file'), defaultValue: 'text' },
+ description: { type: DataTypes.TEXT },
+ category: { type: DataTypes.STRING, defaultValue: 'general' },
+ updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
+}, {
+ timestamps: false,
+ tableName: 'site_settings'
+});
+
+// Определение связей между моделями
+Guides.hasOne(GuideSchedules, { foreignKey: 'guide_id' });
+GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
+
+Guides.hasMany(Holidays, { foreignKey: 'guide_id' });
+Holidays.belongsTo(Guides, { foreignKey: 'guide_id' });
+
+Guides.hasMany(GuideWorkingDays, { foreignKey: 'guide_id' });
+GuideWorkingDays.belongsTo(Guides, { foreignKey: 'guide_id' });
+
+Guides.hasMany(Bookings, { foreignKey: 'guide_id' });
+Bookings.belongsTo(Guides, { foreignKey: 'guide_id' });
+
+Routes.hasMany(Bookings, { foreignKey: 'route_id' });
+Bookings.belongsTo(Routes, { foreignKey: 'route_id' });
+
+// Методы для получения рейтингов
+const getRatingStats = async (targetType, targetId) => {
+ const result = await sequelize.query(
+ 'SELECT * FROM calculate_rating(:targetType, :targetId)',
+ {
+ replacements: { targetType, targetId },
+ type: sequelize.QueryTypes.SELECT
+ }
+ );
+ return result[0] || { likes_count: 0, dislikes_count: 0, total_votes: 0, rating_percentage: 0 };
+};
+
+
+// Конфигурация AdminJS с ресурсами базы данных
+// Конфигурация AdminJS с ресурсами Sequelize
+const adminJsOptions = {
+ locale: {
+ language: 'ru',
+ availableLanguages: ['ru', 'en'],
+ translations: {
+ en: {
+ labels: {
+ routes: 'Routes',
+ 'routes': 'Routes',
+ '🗺️ Маршруты': 'Routes',
+ guides: 'Guides',
+ 'guides': 'Guides',
+ '👥 Гиды': 'Guides',
+ articles: 'Articles',
+ 'articles': 'Articles',
+ '📰 Статьи': 'Articles',
+ bookings: 'Bookings',
+ 'bookings': 'Bookings',
+ 'Заказы': 'Bookings',
+ reviews: 'Reviews',
+ 'reviews': 'Reviews',
+ '⭐ Отзывы': 'Reviews',
+ contact_messages: 'Contact Messages',
+ 'contact_messages': 'Contact Messages',
+ '💌 Сообщения': 'Contact Messages',
+ admins: 'Admins',
+ 'admins': 'Admins',
+ '👨💼 Администраторы': 'Admins',
+ ratings: 'Ratings',
+ 'ratings': 'Ratings',
+ '🌟 Рейтинги': 'Ratings',
+ guide_schedules: 'Guide Schedules',
+ 'guide_schedules': 'Guide Schedules',
+ '📅 Расписание гидов': 'Guide Schedules',
+ '📅 Расписание Гидов': 'Guide Schedules',
+ holidays: 'Holidays',
+ 'holidays': 'Holidays',
+ '🏖️ Выходные дни': 'Holidays',
+ '🏖️ Выходные Дни': 'Holidays',
+ site_settings: 'Site Settings',
+ 'site_settings': 'Site Settings',
+ '⚙️ Настройки сайта': 'Site Settings',
+ '⚙️ Настройки Сайта': 'Site Settings'
+ },
+ pages: {
+ calendar: 'Calendar',
+ 'style-editor': 'Style Editor',
+ 'media-manager': 'Media Manager'
+ }
+ }
+ }
+ },
+ resources: [
+ {
+ resource: Routes,
+ options: {
+ parent: { name: 'Контент', icon: 'DocumentText' },
+ navigation: {
+ name: '🗺️ Маршруты',
+ icon: 'Map'
+ },
+ listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'],
+ editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active'],
+ showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active', 'created_at', 'updated_at'],
+ filterProperties: ['title', 'type', 'is_active'],
+ properties: {
+ title: {
+ isTitle: true,
+ isRequired: true,
+ },
+ description: {
+ type: 'textarea',
+ isRequired: true,
+ },
+ content: {
+ type: 'textarea',
+ },
+ type: {
+ availableValues: [
+ { value: 'city', label: 'Городской тур' },
+ { value: 'mountain', label: 'Горный поход' },
+ { value: 'fishing', label: 'Рыбалка' }
+ ],
+ },
+ difficulty_level: {
+ availableValues: [
+ { value: 'easy', label: 'Легкий' },
+ { value: 'moderate', label: 'Средний' },
+ { value: 'hard', label: 'Сложный' }
+ ],
+ },
+ price: {
+ type: 'number',
+ isRequired: true,
+ },
+ duration: {
+ type: 'number',
+ isRequired: true,
+ },
+ max_group_size: {
+ type: 'number',
+ isRequired: true,
+ },
+ image_url: {
+ type: 'string',
+ description: 'Изображение тура. Кнопка "Выбрать" будет добавлена автоматически'
+ },
+ is_featured: { type: 'boolean' },
+ is_active: { type: 'boolean' },
+ created_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ },
+ updated_at: {
+ isVisible: { list: false, filter: false, show: true, edit: false },
+ }
+ },
+ }
+ },
+ {
+ resource: Guides,
+ options: {
+ parent: { name: 'Персонал', icon: 'Users' },
+ id: 'guides',
+ navigation: {
+ name: '👥 Гиды',
+ icon: 'User'
+ },
+ listProperties: ['id', 'name', 'email', 'specialization', 'experience', 'hourly_rate', 'is_active'],
+ editProperties: ['name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'image_url', 'hourly_rate', 'is_active'],
+ showProperties: ['id', 'name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'image_url', 'hourly_rate', 'is_active', 'created_at'],
+ filterProperties: ['name', 'specialization', 'is_active'],
+ properties: {
+ name: {
+ isTitle: true,
+ isRequired: true,
+ },
+ email: {
+ type: 'email',
+ isRequired: true,
+ },
+ phone: { type: 'string' },
+ languages: {
+ type: 'textarea',
+ description: 'Языки через запятую',
+ },
+ specialization: {
+ availableValues: [
+ { value: 'city', label: 'Городские туры' },
+ { value: 'mountain', label: 'Горные походы' },
+ { value: 'fishing', label: 'Рыбалка' },
+ { value: 'general', label: 'Универсальный' }
+ ],
+ },
+ bio: { type: 'textarea' },
+ experience: {
+ type: 'number',
+ description: 'Опыт работы в годах',
+ },
+ image_url: {
+ type: 'string',
+ description: 'Фотография гида. Кнопка "Выбрать" будет добавлена автоматически'
+ },
+ hourly_rate: {
+ type: 'number',
+ description: 'Ставка за час в вонах',
+ },
+ is_active: { type: 'boolean' },
+ created_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ }
+ },
+ }
+ },
+ {
+ resource: Articles,
+ options: {
+ parent: { name: 'Контент', icon: 'DocumentText' },
+ navigation: {
+ name: '📰 Статьи',
+ icon: 'Book'
+ },
+ listProperties: ['id', 'title', 'category', 'is_published', 'views', 'created_at'],
+ editProperties: ['title', 'excerpt', 'content', 'category', 'image_url', 'is_published'],
+ showProperties: ['id', 'title', 'excerpt', 'content', 'category', 'is_published', 'views', 'created_at', 'updated_at'],
+ filterProperties: ['title', 'category', 'is_published'],
+ properties: {
+ title: {
+ isTitle: true,
+ isRequired: true,
+ },
+ excerpt: {
+ type: 'textarea',
+ description: 'Краткое описание статьи',
+ },
+ content: {
+ type: 'textarea',
+ isRequired: true,
+ },
+ category: {
+ availableValues: [
+ { value: 'travel-tips', label: 'Советы путешественникам' },
+ { value: 'culture', label: 'Культура' },
+ { value: 'food', label: 'Еда' },
+ { value: 'nature', label: 'Природа' },
+ { value: 'history', label: 'История' }
+ ],
+ },
+ image_url: {
+ type: 'string',
+ description: 'Изображение статьи. Кнопка "Выбрать" будет добавлена автоматически'
+ },
+ is_published: { type: 'boolean' },
+ views: {
+ type: 'number',
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ },
+ created_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ },
+ updated_at: {
+ isVisible: { list: false, filter: false, show: true, edit: false },
+ }
+ },
+ }
+ },
+ {
+ resource: Bookings,
+ options: {
+ parent: { name: 'Заказы', icon: 'ShoppingCart' },
+ listProperties: ['id', 'customer_name', 'customer_email', 'preferred_date', 'status', 'total_price', 'created_at'],
+ editProperties: ['customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes'],
+ showProperties: ['id', 'customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes', 'created_at'],
+ filterProperties: ['customer_name', 'customer_email', 'status', 'preferred_date'],
+ properties: {
+ customer_name: {
+ isTitle: true,
+ isRequired: true,
+ },
+ customer_email: {
+ type: 'email',
+ isRequired: true,
+ },
+ customer_phone: { type: 'string' },
+ preferred_date: {
+ type: 'date',
+ isRequired: true,
+ },
+ group_size: {
+ type: 'number',
+ isRequired: true,
+ },
+ status: {
+ availableValues: [
+ { value: 'pending', label: 'В ожидании' },
+ { value: 'confirmed', label: 'Подтверждено' },
+ { value: 'cancelled', label: 'Отменено' },
+ { value: 'completed', label: 'Завершено' }
+ ],
+ },
+ total_price: {
+ type: 'number',
+ isRequired: true,
+ },
+ notes: { type: 'textarea' },
+ created_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ }
+ },
+ }
+ },
+ {
+ resource: Reviews,
+ options: {
+ parent: { name: 'Отзывы', icon: 'Star' },
+ navigation: {
+ name: '⭐ Отзывы',
+ icon: 'MessageCircle'
+ },
+ listProperties: ['id', 'customer_name', 'rating', 'is_approved', 'created_at'],
+ editProperties: ['customer_name', 'customer_email', 'rating', 'comment', 'is_approved'],
+ showProperties: ['id', 'customer_name', 'customer_email', 'rating', 'comment', 'is_approved', 'created_at'],
+ filterProperties: ['customer_name', 'rating', 'is_approved'],
+ properties: {
+ customer_name: {
+ isTitle: true,
+ isRequired: true,
+ },
+ customer_email: { type: 'email' },
+ rating: {
+ type: 'number',
+ availableValues: [
+ { value: 1, label: '1 звезда' },
+ { value: 2, label: '2 звезды' },
+ { value: 3, label: '3 звезды' },
+ { value: 4, label: '4 звезды' },
+ { value: 5, label: '5 звезд' }
+ ]
+ },
+ comment: { type: 'textarea' },
+ is_approved: { type: 'boolean' },
+ created_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ }
+ },
+ }
+ },
+ {
+ resource: ContactMessages,
+ options: {
+ parent: { name: 'Сообщения', icon: 'Mail' },
+ navigation: {
+ name: '💌 Сообщения',
+ icon: 'Mail'
+ },
+ listProperties: ['id', 'name', 'email', 'subject', 'status', 'created_at'],
+ editProperties: ['name', 'email', 'phone', 'subject', 'message', 'status'],
+ showProperties: ['id', 'name', 'email', 'phone', 'subject', 'message', 'status', 'created_at'],
+ filterProperties: ['name', 'email', 'status'],
+ properties: {
+ name: {
+ isTitle: true,
+ isRequired: true,
+ },
+ email: {
+ type: 'email',
+ isRequired: true,
+ },
+ phone: { type: 'string' },
+ subject: {
+ type: 'string',
+ isRequired: true,
+ },
+ message: {
+ type: 'textarea',
+ isRequired: true,
+ },
+ status: {
+ availableValues: [
+ { value: 'unread', label: 'Не прочитано' },
+ { value: 'read', label: 'Прочитано' },
+ { value: 'replied', label: 'Отвечено' }
+ ],
+ },
+ created_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ }
+ },
+ actions: {
+ new: { isAccessible: false },
+ edit: { isAccessible: true },
+ delete: { isAccessible: true },
+ list: { isAccessible: true },
+ show: { isAccessible: true }
+ }
+ }
+ },
+ {
+ resource: Admins,
+ options: {
+ parent: { name: 'Администрирование', icon: 'Settings' },
+ id: 'admins',
+ navigation: {
+ name: '👨💼 Администраторы',
+ icon: 'Shield'
+ },
+ listProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'created_at'],
+ editProperties: ['username', 'name', 'email', 'role', 'is_active'],
+ showProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'last_login', 'created_at'],
+ filterProperties: ['username', 'name', 'role', 'is_active'],
+ properties: {
+ username: {
+ isTitle: true,
+ isRequired: true,
+ },
+ name: {
+ type: 'string',
+ isRequired: true,
+ },
+ email: {
+ type: 'email',
+ isRequired: true,
+ },
+ password: {
+ type: 'password',
+ isVisible: { list: false, filter: false, show: false, edit: true }
+ },
+ role: {
+ availableValues: [
+ { value: 'admin', label: 'Администратор' },
+ { value: 'manager', label: 'Менеджер' },
+ { value: 'editor', label: 'Редактор' }
+ ],
+ },
+ is_active: { type: 'boolean' },
+ last_login: {
+ isVisible: { list: false, filter: false, show: true, edit: false },
+ },
+ created_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ }
+ },
+ }
+ },
+ {
+ resource: Ratings,
+ options: {
+ parent: { name: 'Система рейтингов', icon: 'Star' },
+ navigation: {
+ name: ' 🌟 Рейтинги',
+ icon: 'Star'
+ },
+ listProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
+ showProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
+ filterProperties: ['target_type', 'target_id', 'rating'],
+ properties: {
+ target_type: {
+ availableValues: [
+ { value: 'route', label: 'Маршрут' },
+ { value: 'guide', label: 'Гид' },
+ { value: 'article', label: 'Статья' }
+ ],
+ },
+ rating: {
+ availableValues: [
+ { value: 1, label: '👍 Лайк' },
+ { value: -1, label: '👎 Дизлайк' }
+ ],
+ },
+ created_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ }
+ },
+ actions: {
+ new: { isAccessible: false },
+ edit: { isAccessible: false },
+ delete: { isAccessible: true },
+ list: { isAccessible: true },
+ show: { isAccessible: true }
+ }
+ }
+ },
+ {
+ resource: GuideSchedules,
+ options: {
+ parent: { name: 'Управление гидами', icon: 'Calendar' },
+ navigation: {
+ name: ' 📅 Расписание гидов',
+ icon: 'Calendar'
+ },
+ listProperties: ['id', 'guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time'],
+ editProperties: ['guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time'],
+ showProperties: ['id', 'guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time', 'created_at', 'updated_at'],
+ filterProperties: ['guide_id'],
+ properties: {
+ guide_id: {
+ isTitle: true,
+ isRequired: true,
+ },
+ monday: { type: 'boolean' },
+ tuesday: { type: 'boolean' },
+ wednesday: { type: 'boolean' },
+ thursday: { type: 'boolean' },
+ friday: { type: 'boolean' },
+ saturday: { type: 'boolean' },
+ sunday: { type: 'boolean' },
+ start_time: {
+ type: 'string',
+ description: 'Время начала работы (формат HH:MM)'
+ },
+ end_time: {
+ type: 'string',
+ description: 'Время окончания работы (формат HH:MM)'
+ },
+ created_at: {
+ isVisible: { list: false, filter: false, show: true, edit: false },
+ },
+ updated_at: {
+ isVisible: { list: false, filter: false, show: true, edit: false },
+ }
+ },
+ actions: {
+ list: {
+ after: async (response) => {
+ if (response.records) {
+ response.meta = {
+ ...response.meta,
+ customHeader: `
+
+
+
+
📅 Календарь расписания гидов
+
Визуализация расписания работы, выходных дней и загруженности всех гидов
+
+
+
+
+ `
+ };
+ }
+ return response;
+ }
+ },
+ new: { isVisible: true },
+ edit: { isVisible: true },
+ delete: { isVisible: true },
+ show: { isVisible: true }
+ }
+ }
+ },
+ {
+ resource: Holidays,
+ options: {
+ parent: { name: 'Управление гидами', icon: 'Calendar' },
+ navigation: {
+ name: ' 🏖️ Выходные дни',
+ icon: 'Calendar'
+ },
+ listProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
+ editProperties: ['date', 'title', 'type', 'guide_id'],
+ showProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
+ filterProperties: ['date', 'type', 'guide_id'],
+ properties: {
+ date: {
+ isTitle: true,
+ isRequired: true,
+ type: 'date'
+ },
+ title: {
+ isRequired: true,
+ description: 'Название выходного дня'
+ },
+ type: {
+ availableValues: [
+ { value: 'public', label: 'Общий выходной' },
+ { value: 'guide_personal', label: 'Личный выходной гида' }
+ ],
+ isRequired: true
+ },
+ guide_id: {
+ description: 'Оставить пустым для общих выходных'
+ },
+ created_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ }
+ },
+ }
+ },
+ {
+ resource: GuideWorkingDays,
+ options: {
+ parent: { name: 'Управление гидами', icon: 'Calendar' },
+ navigation: {
+ name: '📅 Календарь гидов',
+ icon: 'Calendar'
+ },
+ listProperties: ['id', 'guide_id', 'work_date', 'notes', 'created_at'],
+ editProperties: ['guide_id', 'work_date', 'notes'],
+ showProperties: ['id', 'guide_id', 'work_date', 'notes', 'created_at'],
+ filterProperties: ['guide_id', 'work_date'],
+ properties: {
+ guide_id: {
+ isTitle: true,
+ isRequired: true,
+ },
+ work_date: {
+ type: 'date',
+ isRequired: true,
+ },
+ notes: {
+ type: 'textarea',
+ description: 'Дополнительные заметки для этого рабочего дня'
+ },
+ created_at: {
+ isVisible: { list: true, filter: false, show: true, edit: false },
+ }
+ },
+ actions: {
+ list: {
+ isVisible: true,
+ after: async (response) => {
+ // Добавляем информацию о календаре
+ if (response.records) {
+ response.meta = response.meta || {};
+ response.meta.total = response.records.length;
+ response.meta.notice = {
+ message: '💡 Для удобного управления расписанием используйте полный календарь',
+ type: 'info'
+ };
+ }
+ return response;
+ }
+ },
+
+
+
+
+
+ Календарь рабочих дней гидов - Админ панель
+
+
+
+
+
+
+
+
+
+
+ Фильтр по гиду:
+
+ Все гиды
+
+
+
+ Специализация:
+
+ Все
+ Городские туры
+ Горные туры
+ Морская рыбалка
+
+
+
Очистить фильтры
+
+
+
+
+ ← Предыдущий месяц
+
Загрузка...
+ Следующий месяц →
+
+
+
+
+
+
+
Загрузка календаря...
+
+
+
+
+
+
+
+
+
Быстрые действия:
+
+
+ 📅 Интерактивный календарь
+
+
+ ⚡ Планировщик смен
+
+
+ 🔄 Обновить данные
+
+
+
+
+
+
+
+`;
+ response.send(html);
+ }
+ },
+ new: {
+ isVisible: true
+ },
+ edit: { isVisible: true },
+ delete: { isVisible: true },
+ show: { isVisible: true }
+ }
+ }
+ },
+ {
+ resource: SiteSettings,
+ options: {
+ parent: { name: 'Администрирование', icon: 'Settings' },
+ id: 'site_settings',
+ navigation: {
+ name: '⚙️ Настройки сайта',
+ icon: 'Settings'
+ },
+ listProperties: ['id', 'setting_key', 'setting_value', 'category', 'updated_at'],
+ editProperties: ['setting_key', 'setting_value', 'setting_type', 'description', 'category'],
+ showProperties: ['id', 'setting_key', 'setting_value', 'setting_type', 'description', 'category', 'updated_at'],
+ filterProperties: ['setting_key', 'category', 'setting_type'],
+ properties: {
+ setting_key: {
+ isTitle: true,
+ isRequired: true,
+ description: 'Уникальный ключ настройки (например: primary_color, hero_background_url)'
+ },
+ setting_value: {
+ type: 'textarea',
+ isRequired: true,
+ description: 'Значение настройки (цвет в HEX, URL изображения, текст и т.д.)'
+ },
+ setting_type: {
+ availableValues: [
+ { value: 'text', label: 'Текст' },
+ { value: 'number', label: 'Число' },
+ { value: 'boolean', label: 'Да/Нет' },
+ { value: 'json', label: 'JSON' },
+ { value: 'color', label: 'Цвет (HEX)' },
+ { value: 'file', label: 'Файл/URL' }
+ ],
+ isRequired: true
+ },
+ description: {
+ type: 'textarea',
+ description: 'Описание назначения этой настройки'
+ },
+ category: {
+ availableValues: [
+ { value: 'general', label: 'Общие' },
+ { value: 'theme', label: 'Тема и стили' },
+ { value: 'colors', label: 'Цвета' },
+ { value: 'typography', label: 'Типографика' },
+ { value: 'images', label: 'Изображения' },
+ { value: 'effects', label: 'Эффекты' },
+ { value: 'layout', label: 'Макет' }
+ ],
+ defaultValue: 'general'
+ },
+ updated_at: {
+ isVisible: { list: true, filter: true, show: true, edit: false },
+ }
+ },
+ }
+ }
+ ],
+ navigation: {
+ 'Администрирование': {
+ name: 'Администрирование',
+ icon: 'Settings'
+ }
+ },
+ rootPath: '/admin',
+ branding: {
+ companyName: 'Korea Tourism Agency',
+ softwareBrothers: false,
+ theme: {
+ colors: {
+ primary100: '#ff6b6b',
+ primary80: '#ff5252',
+ primary60: '#ff3d3d',
+ primary40: '#ff2828',
+ primary20: '#ff1313',
+ grey100: '#151515',
+ grey80: '#333333',
+ grey60: '#666666',
+ grey40: '#999999',
+ grey20: '#cccccc',
+ filterBg: '#333333',
+ accent: '#38C172',
+ hoverBg: '#f0f0f0',
+ },
+ },
+ },
+ dashboard: {
+ handler: (req, res) => {
+ const html = `
+
+
+
+
+
+ Админ панель - Главная
+
+
+
+
+
🚀 Админ панель Korea Tourism
+
+
+
Новая функция: Календарь гидов!
+
Теперь доступен календарь работы гидов с отображением расписания, выходных дней и загруженности.
+ Поиск туров учитывает доступность гидов на конкретную дату.
+
+
+
+
+
+
+
+
+
+
+ `;
+ res.send(html);
+ }
+ },
+ actions: {
+ 'style-editor': {
+ actionType: 'record',
+ icon: 'Settings',
+ label: '🎨 Редактор стилей',
+ handler: (request, response, data) => {
+ return {
+ redirectUrl: '/admin/style-editor'
+ }
+ }
+ },
+ 'image-manager': {
+ actionType: 'record',
+ icon: 'Image',
+ label: '🖼️ Менеджер изображений',
+ handler: (request, response, data) => {
+ return {
+ redirectUrl: '/admin/image-manager'
+ }
+ }
+ }
+ },
+ assets: {
+ styles: ['/css/admin-custom.css'],
+ scripts: [
+ '/js/admin-image-selector-fixed.js',
+ '/js/universal-media-manager-integration.js'
+ ]
+ }
+};
+
+// Создаем экземпляр AdminJS с componentLoader
+// Создание AdminJS с конфигурацией
+const adminJs = new AdminJS(adminJsOptions);
+
+// Настраиваем аутентификацию
+const router = AdminJSExpress.buildAuthenticatedRouter(adminJs, {
+ authenticate: async (email, password) => {
+ try {
+ console.log('Attempting login for:', email);
+
+ const result = await authPool.query(
+ 'SELECT * FROM admins WHERE username = $1 AND is_active = true',
+ [email]
+ );
+
+ if (result.rows.length === 0) {
+ console.log('No admin found with username:', email);
+ return null;
+ }
+
+ const admin = result.rows[0];
+ console.log('Admin found:', admin.name);
+
+ const isValid = await bcrypt.compare(password, admin.password);
+
+ if (isValid) {
+ console.log('Authentication successful for:', email);
+ return {
+ id: admin.id,
+ email: admin.username,
+ title: admin.name,
+ role: admin.role
+ };
+ }
+
+ console.log('Invalid password for:', email);
+ return null;
+ } catch (error) {
+ console.error('Auth error:', error);
+ return null;
+ }
+ },
+ cookiePassword: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024'
+}, null, {
+ resave: true,
+ saveUninitialized: true,
+ secret: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024',
+ name: 'adminjs-session',
+ cookie: {
+ httpOnly: true,
+ secure: false, // Отключаем secure для development
+ maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
+ sameSite: 'lax'
+ },
+ rolling: true // Продлеваем сессию при каждом запросе
+});
+
+// Добавляем дополнительные роуты после аутентификации
+router.get('/calendar-view', async (request, response) => {
+ try {
+ const calendarPath = path.join(__dirname, '../../public/admin-calendar-component.html');
+ const calendarHtml = fs.readFileSync(calendarPath, 'utf8');
+ response.send(calendarHtml);
+ } catch (error) {
+ response.status(500).send('Error loading calendar');
+ }
+});
+
+router.get('/schedule-manager', async (request, response) => {
+ try {
+ const schedulePath = path.join(__dirname, '../../public/schedule-manager.html');
+ const scheduleHtml = fs.readFileSync(schedulePath, 'utf8');
+ response.send(scheduleHtml);
+ } catch (error) {
+ response.status(500).send('Error loading schedule manager');
+ }
+});
+
+export { adminJs, router };
\ No newline at end of file
diff --git a/src/routes/admin-calendar.js b/src/routes/admin-calendar.js
new file mode 100644
index 0000000..a0c16a3
--- /dev/null
+++ b/src/routes/admin-calendar.js
@@ -0,0 +1,35 @@
+import express from 'express';
+import path from 'path';
+import { fileURLToPath } from 'url';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+const router = express.Router();
+
+// Календарь для просмотра в AdminJS
+router.get('/calendar-view', (req, res) => {
+ res.sendFile(path.join(__dirname, '../../public/admin-calendar-component.html'));
+});
+
+// Основной календарь
+router.get('/calendar', (req, res) => {
+ res.sendFile(path.join(__dirname, '../../public/guide-calendar.html'));
+});
+
+// Редактор стилей
+router.get('/style-editor', (req, res) => {
+ res.sendFile(path.join(__dirname, '../../public/professional-style-editor.html'));
+});
+
+// Менеджер изображений
+router.get('/image-manager', (req, res) => {
+ res.sendFile(path.join(__dirname, '../../public/universal-media-manager.html'));
+});
+
+// Планировщик рабочих смен
+router.get('/schedule-manager', (req, res) => {
+ res.sendFile(path.join(__dirname, '../../public/schedule-manager.html'));
+});
+
+export default router;
\ No newline at end of file
diff --git a/src/routes/admin-tools.js b/src/routes/admin-tools.js
new file mode 100644
index 0000000..e422365
--- /dev/null
+++ b/src/routes/admin-tools.js
@@ -0,0 +1,84 @@
+import express from 'express';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { dirname } from 'path';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const router = express.Router();
+
+// Маршрут для редактора стилей
+router.get('/admin/style-editor', (req, res) => {
+ const html = `
+
+
+
+
+
+ 🎨 Редактор стилей
+
+
+
+
+
+
+ `;
+ res.send(html);
+});
+
+// Маршрут для менеджера изображений
+router.get('/admin/image-manager', (req, res) => {
+ const html = `
+
+
+
+
+
+ 🖼️ Менеджер изображений
+
+
+
+
+
+
+ `;
+ res.send(html);
+});
+
+export default router;
\ No newline at end of file
diff --git a/src/routes/api.js b/src/routes/api.js
index 984d6d1..92a7bf4 100644
--- a/src/routes/api.js
+++ b/src/routes/api.js
@@ -101,14 +101,63 @@ router.post('/booking', async (req, res) => {
guide_id
} = req.body;
+ // Проверяем доступность гида на указанную дату
+ const searchDate = new Date(preferred_date);
+ const dayOfWeek = searchDate.getDay();
+ const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
+ const dayName = dayNames[dayOfWeek];
+
+ // Проверяем расписание гида
+ const scheduleCheck = await db.query(`
+ SELECT * FROM guide_schedules
+ WHERE guide_id = $1 AND ${dayName} = true
+ `, [guide_id]);
+
+ if (scheduleCheck.rows.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: 'Гид не работает в этот день недели'
+ });
+ }
+
+ // Проверяем выходные дни
+ const holidayCheck = await db.query(`
+ SELECT id FROM holidays
+ WHERE guide_id = $1 AND date = $2
+ `, [guide_id, preferred_date]);
+
+ if (holidayCheck.rows.length > 0) {
+ return res.status(400).json({
+ success: false,
+ message: 'У гида выходной в этот день'
+ });
+ }
+
+ // Проверяем количество существующих бронирований
+ const existingBookings = await db.query(`
+ SELECT COUNT(*) as count
+ FROM bookings
+ WHERE guide_id = $1
+ AND preferred_date = $2
+ AND status != 'cancelled'
+ `, [guide_id, preferred_date]);
+
+ if (parseInt(existingBookings.rows[0].count) >= 3) {
+ return res.status(400).json({
+ success: false,
+ message: 'На эту дату у гида больше нет свободных мест'
+ });
+ }
+
+ // Создаем бронирование
const booking = await db.query(`
INSERT INTO bookings (
route_id, guide_id, customer_name, customer_email,
customer_phone, preferred_date, group_size,
- special_requirements, status
+ special_requirements, status, booking_date, booking_time
)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
- RETURNING id
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $6, CURRENT_TIME)
+ RETURNING id, preferred_date
`, [
route_id, guide_id, customer_name, customer_email,
customer_phone, preferred_date, group_size,
@@ -117,14 +166,14 @@ router.post('/booking', async (req, res) => {
res.json({
success: true,
- message: 'Booking request submitted successfully! We will contact you soon.',
+ message: 'Заявка на бронирование успешно отправлена! Мы свяжемся с вами в ближайшее время.',
booking_id: booking.rows[0].id
});
} catch (error) {
console.error('API error submitting booking:', error);
res.status(500).json({
success: false,
- message: 'Error submitting booking request'
+ message: 'Ошибка при отправке заявки на бронирование'
});
}
});
@@ -239,4 +288,289 @@ router.post('/reviews', async (req, res) => {
}
});
+// Statistics endpoint for admin dashboard
+router.get('/stats', async (req, res) => {
+ try {
+ const [routes, guides, articles, bookings] = await Promise.all([
+ db.query('SELECT COUNT(*) FROM routes WHERE is_active = true'),
+ db.query('SELECT COUNT(*) FROM guides WHERE is_active = true'),
+ db.query('SELECT COUNT(*) FROM articles WHERE is_active = true'),
+ db.query('SELECT COUNT(*) FROM bookings')
+ ]);
+
+ res.json({
+ success: true,
+ routes: parseInt(routes.rows[0].count),
+ guides: parseInt(guides.rows[0].count),
+ articles: parseInt(articles.rows[0].count),
+ bookings: parseInt(bookings.rows[0].count)
+ });
+ } catch (error) {
+ console.error('API error loading stats:', error);
+ res.status(500).json({
+ success: false,
+ message: 'Ошибка загрузки статистики'
+ });
+ }
+});
+
+// API для календаря гидов
+router.get('/guides', async (req, res) => {
+ try {
+ const guides = await db.query(`
+ SELECT id, name, email, phone, languages, specializations, bio
+ FROM guides
+ WHERE is_active = true
+ ORDER BY name
+ `);
+
+ res.json(guides.rows);
+ } catch (error) {
+ console.error('Ошибка получения гидов:', error);
+ res.status(500).json({ error: 'Внутренняя ошибка сервера' });
+ }
+});
+
+router.get('/guide-schedules', async (req, res) => {
+ try {
+ const schedules = await db.query(`
+ SELECT * FROM guide_schedules
+ `);
+
+ res.json(schedules.rows);
+ } catch (error) {
+ console.error('Ошибка получения расписаний:', error);
+ res.status(500).json({ error: 'Внутренняя ошибка сервера' });
+ }
+});
+
+router.get('/holidays', async (req, res) => {
+ try {
+ const holidays = await db.query(`
+ SELECT * FROM holidays
+ WHERE date >= CURRENT_DATE - INTERVAL '30 days'
+ AND date <= CURRENT_DATE + INTERVAL '60 days'
+ ORDER BY date
+ `);
+
+ res.json(holidays.rows);
+ } catch (error) {
+ console.error('Ошибка получения выходных дней:', error);
+ res.status(500).json({ error: 'Внутренняя ошибка сервера' });
+ }
+});
+
+router.get('/bookings', async (req, res) => {
+ try {
+ const bookings = await db.query(`
+ SELECT b.*, g.name as guide_name, r.title as route_title
+ FROM bookings b
+ LEFT JOIN guides g ON b.guide_id = g.id
+ LEFT JOIN routes r ON b.route_id = r.id
+ WHERE b.preferred_date >= CURRENT_DATE - INTERVAL '7 days'
+ AND b.preferred_date <= CURRENT_DATE + INTERVAL '60 days'
+ ORDER BY b.preferred_date, b.created_at
+ `);
+
+ res.json(bookings.rows);
+ } catch (error) {
+ console.error('Ошибка получения бронирований:', error);
+ res.status(500).json({ error: 'Внутренняя ошибка сервера' });
+ }
+});
+
+// Обновленный поиск доступных туров с учётом графика гидов
+router.get('/search-available', async (req, res) => {
+ try {
+ const { destination, date, people } = req.query;
+
+ if (!date) {
+ return res.status(400).json({ error: 'Дата обязательна для поиска' });
+ }
+
+ // Определяем день недели для указанной даты
+ const searchDate = new Date(date);
+ const dayOfWeek = searchDate.getDay();
+ const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
+ const dayName = dayNames[dayOfWeek];
+
+ let query = `
+ SELECT DISTINCT
+ r.id, r.title, r.description, r.type, r.duration,
+ r.price, r.difficulty_level, r.max_group_size, r.image_url,
+ g.id as guide_id, g.name as guide_name, g.languages as guide_languages,
+ gs.start_time, gs.end_time
+ FROM routes r
+ INNER JOIN guides g ON g.is_active = true
+ INNER JOIN guide_schedules gs ON g.id = gs.guide_id
+ LEFT JOIN holidays h ON (g.id = h.guide_id AND h.date = $1)
+ WHERE r.is_active = true
+ AND gs.${dayName} = true
+ AND h.id IS NULL
+ `;
+
+ const params = [date];
+
+ if (destination) {
+ query += ` AND (r.title ILIKE $${params.length + 1} OR r.description ILIKE $${params.length + 1})`;
+ params.push(`%${destination}%`);
+ }
+
+ // Проверяем размер группы
+ if (people) {
+ query += ` AND r.max_group_size >= $${params.length + 1}`;
+ params.push(parseInt(people));
+ }
+
+ query += ` ORDER BY r.title`;
+
+ const results = await db.query(query, params);
+
+ // Проверяем занятость гидов на указанную дату
+ const availableRoutes = [];
+
+ for (const route of results.rows) {
+ // Проверяем существующие бронирования для гида на эту дату
+ const existingBookings = await db.query(`
+ SELECT COUNT(*) as booking_count
+ FROM bookings
+ WHERE guide_id = $1
+ AND preferred_date = $2
+ AND status != 'cancelled'
+ `, [route.guide_id, date]);
+
+ const bookingCount = parseInt(existingBookings.rows[0]?.booking_count || 0);
+
+ // Считаем, что гид может вести до 3 групп в день
+ if (bookingCount < 3) {
+ availableRoutes.push({
+ ...route,
+ guide_available: true,
+ available_slots: 3 - bookingCount
+ });
+ }
+ }
+
+ res.json({
+ success: true,
+ data: availableRoutes
+ });
+ } catch (error) {
+ console.error('Ошибка поиска:', error);
+ res.status(500).json({ error: 'Внутренняя ошибка сервера' });
+ }
+});
+
+// Get tours available on specific date
+router.get('/tours-by-date', async (req, res) => {
+ try {
+ const { date } = req.query;
+
+ if (!date) {
+ return res.status(400).json({
+ success: false,
+ error: 'Дата не указана'
+ });
+ }
+
+ // Получаем гидов, которые работают в указанную дату
+ const workingGuidesQuery = `
+ SELECT gwd.guide_id, g.name as guide_name, g.specialization, g.hourly_rate
+ FROM guide_working_days gwd
+ JOIN guides g ON g.id = gwd.guide_id
+ WHERE gwd.work_date = $1 AND g.is_active = true
+ `;
+
+ const workingGuides = await db.query(workingGuidesQuery, [date]);
+
+ if (workingGuides.rows.length === 0) {
+ return res.json({
+ success: true,
+ data: [],
+ message: 'В этот день нет доступных гидов'
+ });
+ }
+
+ // Получаем туры для работающих гидов
+ const routesQuery = `
+ SELECT DISTINCT r.*, g.name as guide_name, g.specialization as guide_specialization,
+ g.hourly_rate, gwd.notes as guide_notes
+ FROM routes r
+ JOIN guides g ON r.guide_id = g.id
+ JOIN guide_working_days gwd ON g.id = gwd.guide_id
+ WHERE gwd.work_date = $1
+ AND r.is_active = true
+ AND g.is_active = true
+ AND (r.type = g.specialization OR r.guide_id = g.id)
+ ORDER BY r.type, r.title
+ `;
+
+ const routes = await db.query(routesQuery, [date]);
+
+ res.json({
+ success: true,
+ data: routes.rows,
+ working_guides: workingGuides.rows,
+ date: date
+ });
+ } catch (error) {
+ console.error('Ошибка получения туров по дате:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Внутренняя ошибка сервера'
+ });
+ }
+});
+
+// Get calendar data for tours
+router.get('/tours-calendar', async (req, res) => {
+ try {
+ const { month, year } = req.query;
+ const currentYear = year || new Date().getFullYear();
+ const currentMonth = month || (new Date().getMonth() + 1);
+
+ // Получаем все рабочие дни в указанном месяце
+ const startDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-01`;
+ const endDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-31`;
+
+ const calendarQuery = `
+ SELECT
+ gwd.work_date,
+ COUNT(DISTINCT gwd.guide_id) as guides_count,
+ COUNT(DISTINCT r.id) as routes_count,
+ JSON_AGG(DISTINCT jsonb_build_object(
+ 'guide_id', g.id,
+ 'guide_name', g.name,
+ 'specialization', g.specialization,
+ 'route_count', (
+ SELECT COUNT(*)
+ FROM routes r2
+ WHERE r2.guide_id = g.id AND r2.is_active = true
+ )
+ )) as guides_data
+ FROM guide_working_days gwd
+ JOIN guides g ON g.id = gwd.guide_id
+ LEFT JOIN routes r ON (r.guide_id = g.id OR r.type = g.specialization) AND r.is_active = true
+ WHERE gwd.work_date >= $1 AND gwd.work_date <= $2 AND g.is_active = true
+ GROUP BY gwd.work_date
+ ORDER BY gwd.work_date
+ `;
+
+ const calendarData = await db.query(calendarQuery, [startDate, endDate]);
+
+ res.json({
+ success: true,
+ data: calendarData.rows,
+ month: currentMonth,
+ year: currentYear
+ });
+ } catch (error) {
+ console.error('Ошибка получения календарных данных:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Внутренняя ошибка сервера'
+ });
+ }
+});
+
export default router;
\ No newline at end of file
diff --git a/src/routes/guide-schedules.js b/src/routes/guide-schedules.js
new file mode 100644
index 0000000..fc13201
--- /dev/null
+++ b/src/routes/guide-schedules.js
@@ -0,0 +1,303 @@
+import express from 'express';
+import db from '../config/database.js';
+const router = express.Router();
+
+// Получение расписания гидов за месяц
+router.get('/', async (req, res) => {
+ try {
+ const { year, month, guide_id } = req.query;
+
+ let query = `
+ SELECT gwd.*, g.name as guide_name, g.specialization
+ FROM guide_working_days gwd
+ LEFT JOIN guides g ON gwd.guide_id = g.id
+ WHERE 1=1
+ `;
+ const params = [];
+
+ if (year && month) {
+ query += ` AND EXTRACT(YEAR FROM gwd.work_date) = $${params.length + 1} AND EXTRACT(MONTH FROM gwd.work_date) = $${params.length + 2}`;
+ params.push(year, month);
+ }
+
+ if (guide_id) {
+ query += ` AND gwd.guide_id = $${params.length + 1}`;
+ params.push(guide_id);
+ }
+
+ query += ` ORDER BY gwd.work_date, g.name`;
+
+ const result = await db.query(query, params);
+ res.json({ success: true, data: result.rows });
+ } catch (error) {
+ console.error('Ошибка получения расписания:', error);
+ res.status(500).json({ success: false, error: 'Ошибка получения расписания' });
+ }
+});
+
+// Массовое создание/обновление расписания гидов за месяц
+router.put('/', async (req, res) => {
+ try {
+ const { year, month } = req.query;
+ const { schedules } = req.body;
+
+ if (!year || !month || !schedules || !Array.isArray(schedules)) {
+ return res.status(400).json({
+ success: false,
+ error: 'Требуются параметры year, month и массив schedules'
+ });
+ }
+
+ await db.query('BEGIN');
+
+ // Удаляем существующее расписание за этот месяц
+ await db.query(`
+ DELETE FROM guide_working_days
+ WHERE EXTRACT(YEAR FROM work_date) = $1
+ AND EXTRACT(MONTH FROM work_date) = $2
+ `, [year, month]);
+
+ // Добавляем новое расписание
+ if (schedules.length > 0) {
+ const values = schedules.map((schedule, index) => {
+ const baseIndex = index * 2;
+ return `($${baseIndex + 1}, $${baseIndex + 2})`;
+ }).join(', ');
+
+ const params = schedules.flatMap(schedule => [
+ schedule.guide_id,
+ schedule.work_date
+ ]);
+
+ await db.query(`
+ INSERT INTO guide_schedules (guide_id, work_date)
+ VALUES ${values}
+ `, params);
+ }
+
+ await db.query('COMMIT');
+
+ res.json({
+ success: true,
+ message: `Расписание за ${year}-${month.toString().padStart(2, '0')} обновлено`,
+ count: schedules.length
+ });
+ } catch (error) {
+ await db.query('ROLLBACK');
+ console.error('Ошибка обновления расписания:', error);
+ res.status(500).json({ success: false, error: 'Ошибка обновления расписания' });
+ }
+});
+
+// Массовое добавление расписания (для копирования)
+router.post('/batch', async (req, res) => {
+ try {
+ const { schedules } = req.body;
+
+ if (!schedules || !Array.isArray(schedules)) {
+ return res.status(400).json({
+ success: false,
+ error: 'Требуется массив schedules'
+ });
+ }
+
+ if (schedules.length === 0) {
+ return res.json({ success: true, message: 'Нет расписания для добавления' });
+ }
+
+ await db.query('BEGIN');
+
+ // Проверяем существующие записи и добавляем только новые
+ const existingQuery = `
+ SELECT guide_id, work_date
+ FROM guide_working_days
+ WHERE (guide_id, work_date) IN (${schedules.map((_, index) => {
+ const baseIndex = index * 2;
+ return `($${baseIndex + 1}, $${baseIndex + 2})`;
+ }).join(', ')})
+ `;
+
+ const existingParams = schedules.flatMap(schedule => [
+ schedule.guide_id,
+ schedule.work_date
+ ]);
+
+ const existingResult = await db.query(existingQuery, existingParams);
+ const existingSet = new Set(
+ existingResult.rows.map(row => `${row.guide_id}-${row.work_date}`)
+ );
+
+ // Фильтруем новые записи
+ const newSchedules = schedules.filter(schedule => {
+ const key = `${schedule.guide_id}-${schedule.work_date}`;
+ return !existingSet.has(key);
+ });
+
+ if (newSchedules.length > 0) {
+ const values = newSchedules.map((schedule, index) => {
+ const baseIndex = index * 2;
+ return `($${baseIndex + 1}, $${baseIndex + 2})`;
+ }).join(', ');
+
+ const params = newSchedules.flatMap(schedule => [
+ schedule.guide_id,
+ schedule.work_date
+ ]);
+
+ await db.query(`
+ INSERT INTO guide_schedules (guide_id, work_date)
+ VALUES ${values}
+ `, params);
+ }
+
+ await db.query('COMMIT');
+
+ res.json({
+ success: true,
+ message: 'Расписание добавлено',
+ added: newSchedules.length,
+ skipped: schedules.length - newSchedules.length
+ });
+ } catch (error) {
+ await db.query('ROLLBACK');
+ console.error('Ошибка массового добавления расписания:', error);
+ res.status(500).json({ success: false, error: 'Ошибка добавления расписания' });
+ }
+});
+
+// Добавление одного рабочего дня
+router.post('/', async (req, res) => {
+ try {
+ const { guide_id, work_date } = req.body;
+
+ if (!guide_id || !work_date) {
+ return res.status(400).json({
+ success: false,
+ error: 'Требуются параметры guide_id и work_date'
+ });
+ }
+
+ // Проверяем, что запись не существует
+ const existingResult = await db.query(
+ 'SELECT id FROM guide_working_days WHERE guide_id = $1 AND work_date = $2',
+ [guide_id, work_date]
+ );
+
+ if (existingResult.rows.length > 0) {
+ return res.json({
+ success: true,
+ message: 'Рабочий день уже существует'
+ });
+ }
+
+ const result = await db.query(`
+ INSERT INTO guide_working_days (guide_id, work_date)
+ VALUES ($1, $2)
+ RETURNING *
+ `, [guide_id, work_date]);
+
+ res.json({
+ success: true,
+ data: result.rows[0],
+ message: 'Рабочий день добавлен'
+ });
+ } catch (error) {
+ console.error('Ошибка добавления рабочего дня:', error);
+ res.status(500).json({ success: false, error: 'Ошибка добавления рабочего дня' });
+ }
+});
+
+// Удаление рабочего дня
+router.delete('/', async (req, res) => {
+ try {
+ const { guide_id, work_date } = req.body;
+
+ if (!guide_id || !work_date) {
+ return res.status(400).json({
+ success: false,
+ error: 'Требуются параметры guide_id и work_date'
+ });
+ }
+
+ const result = await db.query(`
+ DELETE FROM guide_working_days
+ WHERE guide_id = $1 AND work_date = $2
+ RETURNING *
+ `, [guide_id, work_date]);
+
+ if (result.rows.length === 0) {
+ return res.status(404).json({
+ success: false,
+ error: 'Рабочий день не найден'
+ });
+ }
+
+ res.json({
+ success: true,
+ message: 'Рабочий день удален'
+ });
+ } catch (error) {
+ console.error('Ошибка удаления рабочего дня:', error);
+ res.status(500).json({ success: false, error: 'Ошибка удаления рабочего дня' });
+ }
+});
+
+// Получение статистики работы гидов
+router.get('/stats', async (req, res) => {
+ try {
+ const { year, month } = req.query;
+
+ let dateFilter = '';
+ const params = [];
+
+ if (year && month) {
+ dateFilter = 'WHERE EXTRACT(YEAR FROM gwd.work_date) = $1 AND EXTRACT(MONTH FROM gwd.work_date) = $2';
+ params.push(year, month);
+ }
+
+ const statsQuery = `
+ SELECT
+ g.id,
+ g.name,
+ g.specialization,
+ COUNT(gwd.work_date) as working_days,
+ MIN(gwd.work_date) as first_work_date,
+ MAX(gwd.work_date) as last_work_date
+ FROM guides g
+ LEFT JOIN guide_working_days gwd ON g.id = gwd.guide_id
+ ${dateFilter}
+ GROUP BY g.id, g.name, g.specialization
+ ORDER BY working_days DESC, g.name
+ `;
+
+ const result = await db.query(statsQuery, params);
+
+ // Общая статистика
+ const totalStats = await db.query(`
+ SELECT
+ COUNT(DISTINCT gwd.guide_id) as active_guides,
+ COUNT(gwd.work_date) as total_working_days,
+ ROUND(AVG(guide_days.working_days), 1) as avg_days_per_guide
+ FROM (
+ SELECT guide_id, COUNT(work_date) as working_days
+ FROM guide_working_days gwd
+ ${dateFilter}
+ GROUP BY guide_id
+ ) guide_days
+ RIGHT JOIN guides g ON guide_days.guide_id = g.id
+ `, params);
+
+ res.json({
+ success: true,
+ data: {
+ guides: result.rows,
+ summary: totalStats.rows[0]
+ }
+ });
+ } catch (error) {
+ console.error('Ошибка получения статистики:', error);
+ res.status(500).json({ success: false, error: 'Ошибка получения статистики' });
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/src/routes/images.js b/src/routes/images.js
index 9b606b5..98136a5 100644
--- a/src/routes/images.js
+++ b/src/routes/images.js
@@ -14,7 +14,9 @@ const router = express.Router();
// Настройка multer для загрузки файлов
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
- const uploadDir = path.join(__dirname, '../../public/uploads');
+ // Определяем папку на основе параметра или используем 'general'
+ const folder = req.body.folder || req.query.folder || 'general';
+ const uploadDir = path.join(__dirname, `../../public/uploads/${folder}`);
try {
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
@@ -25,7 +27,7 @@ const storage = multer.diskStorage({
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
- cb(null, `image-${uniqueSuffix}${ext}`);
+ cb(null, `${uniqueSuffix}${ext}`);
}
});
@@ -256,4 +258,235 @@ router.get('/info/:folder/:filename', async (req, res) => {
}
});
+// GET /api/images/gallery - получение списка всех изображений
+router.get('/gallery', async (req, res) => {
+ try {
+ const uploadsDir = path.join(__dirname, '../../public/uploads');
+ const images = [];
+
+ // Функция для рекурсивного сканирования папок
+ async function scanDirectory(dir, relativePath = '') {
+ try {
+ const items = await fs.readdir(dir);
+
+ for (const item of items) {
+ const itemPath = path.join(dir, item);
+ const itemRelativePath = path.join(relativePath, item);
+ const stats = await fs.stat(itemPath);
+
+ if (stats.isDirectory()) {
+ // Рекурсивно сканируем подпапки
+ await scanDirectory(itemPath, itemRelativePath);
+ } else if (stats.isFile()) {
+ // Проверяем, что это изображение
+ const ext = path.extname(item).toLowerCase();
+ if (['.jpg', '.jpeg', '.png', '.gif', '.webp'].includes(ext)) {
+ const webPath = '/uploads/' + itemRelativePath.replace(/\\/g, '/');
+ images.push({
+ path: webPath,
+ name: item,
+ size: stats.size,
+ folder: path.dirname(itemRelativePath) || 'root',
+ created: stats.birthtime,
+ modified: stats.mtime
+ });
+ }
+ }
+ }
+ } catch (error) {
+ console.error(`Ошибка сканирования папки ${dir}:`, error);
+ }
+ }
+
+ await scanDirectory(uploadsDir);
+
+ // Сортируем по дате изменения (новые сначала)
+ images.sort((a, b) => new Date(b.modified) - new Date(a.modified));
+
+ res.json({
+ success: true,
+ images,
+ total: images.length
+ });
+
+ } catch (error) {
+ console.error('Gallery error:', error);
+ res.status(500).json({
+ success: false,
+ error: error.message || 'Ошибка загрузки галереи'
+ });
+ }
+});
+
+// DELETE /api/images/delete - удаление изображения по пути
+router.delete('/delete', async (req, res) => {
+ try {
+ const { path: imagePath } = req.body;
+
+ if (!imagePath) {
+ return res.status(400).json({
+ success: false,
+ error: 'Не указан путь к изображению'
+ });
+ }
+
+ // Безопасность: проверяем, что путь начинается с /uploads/
+ if (!imagePath.startsWith('/uploads/')) {
+ return res.status(400).json({
+ success: false,
+ error: 'Недопустимый путь к файлу'
+ });
+ }
+
+ const filePath = path.join(__dirname, '../../public', imagePath);
+
+ // Проверяем существование файла
+ try {
+ await fs.access(filePath);
+ } catch (err) {
+ return res.status(404).json({
+ success: false,
+ error: 'Файл не найден'
+ });
+ }
+
+ // Удаляем файл
+ await fs.unlink(filePath);
+
+ res.json({
+ success: true,
+ message: 'Изображение успешно удалено'
+ });
+
+ } catch (error) {
+ console.error('Delete error:', error);
+ res.status(500).json({
+ success: false,
+ error: error.message || 'Ошибка удаления изображения'
+ });
+ }
+});
+
+// POST /api/images/upload - множественная загрузка изображений
+router.post('/upload', upload.array('images', 10), async (req, res) => {
+ try {
+ if (!req.files || req.files.length === 0) {
+ return res.status(400).json({
+ success: false,
+ message: 'Файлы не выбраны'
+ });
+ }
+
+ const uploadedFiles = [];
+ const folder = req.body.folder || 'general';
+
+ for (const file of req.files) {
+ try {
+ // Оптимизируем изображение
+ const optimizedPath = file.path.replace(path.extname(file.path), '_optimized' + path.extname(file.path));
+
+ await sharp(file.path)
+ .resize(1200, 1200, {
+ fit: 'inside',
+ withoutEnlargement: true
+ })
+ .jpeg({ quality: 85 })
+ .png({ quality: 85 })
+ .toFile(optimizedPath);
+
+ // Заменяем оригинал оптимизированным
+ await fs.unlink(file.path);
+ await fs.rename(optimizedPath, file.path);
+
+ // Получаем информацию о файле
+ const stats = await fs.stat(file.path);
+ const relativePath = `/uploads/${folder}/${path.basename(file.path)}`;
+
+ uploadedFiles.push({
+ name: path.basename(file.path),
+ path: relativePath,
+ fullPath: file.path,
+ size: stats.size,
+ folder: folder,
+ created: stats.birthtime,
+ modified: stats.mtime
+ });
+ } catch (fileError) {
+ console.error('File processing error:', fileError);
+ // Продолжаем обработку других файлов
+ }
+ }
+
+ if (uploadedFiles.length === 0) {
+ return res.status(500).json({
+ success: false,
+ message: 'Ошибка обработки файлов'
+ });
+ }
+
+ res.json({
+ success: true,
+ message: `Загружено ${uploadedFiles.length} файл(ов)`,
+ data: uploadedFiles
+ });
+
+ } catch (error) {
+ console.error('Upload error:', error);
+ res.status(500).json({
+ success: false,
+ error: error.message || 'Ошибка загрузки изображений'
+ });
+ }
+});
+
+// DELETE /api/images/delete/:filename - удаление по имени файла
+router.delete('/delete/:filename', async (req, res) => {
+ try {
+ const { filename } = req.params;
+
+ // Ищем файл во всех папках
+ const folders = ['general', 'routes', 'guides', 'articles'];
+ let foundPath = null;
+ let foundFolder = null;
+
+ for (const folder of folders) {
+ const filePath = path.join(__dirname, `../../public/uploads/${folder}`, filename);
+ try {
+ await fs.access(filePath);
+ foundPath = filePath;
+ foundFolder = folder;
+ break;
+ } catch {
+ // Файл не найден в этой папке
+ }
+ }
+
+ if (!foundPath) {
+ return res.status(404).json({
+ success: false,
+ message: 'Файл не найден'
+ });
+ }
+
+ // Удаляем файл
+ await fs.unlink(foundPath);
+
+ res.json({
+ success: true,
+ message: 'Файл удален',
+ deleted: {
+ filename,
+ folder: foundFolder
+ }
+ });
+
+ } catch (error) {
+ console.error('Delete error:', error);
+ res.status(500).json({
+ success: false,
+ error: error.message || 'Ошибка удаления файла'
+ });
+ }
+});
+
export default router;
\ No newline at end of file
diff --git a/src/routes/routes.js b/src/routes/routes.js
index 1aa5d63..3cd2375 100644
--- a/src/routes/routes.js
+++ b/src/routes/routes.js
@@ -88,4 +88,42 @@ router.get('/:id', async (req, res) => {
}
});
+// Booking page for a specific route
+router.get('/:id/booking', async (req, res) => {
+ try {
+ const routeId = parseInt(req.params.id);
+
+ // Get route details
+ const routeResult = await db.query(`
+ SELECT r.*, g.name as guide_name, g.bio as guide_bio, g.image_url as guide_image
+ FROM routes r
+ LEFT JOIN guides g ON r.guide_id = g.id
+ WHERE r.id = $1 AND r.is_active = true
+ `, [routeId]);
+
+ if (routeResult.rows.length === 0) {
+ return res.status(404).render('error', {
+ title: 'Тур не найден',
+ message: 'Запрашиваемый тур не существует или недоступен.',
+ page: 'error'
+ });
+ }
+
+ const route = routeResult.rows[0];
+
+ res.render('routes/booking', {
+ title: `Бронирование: ${route.title} - Корея Тур Агентство`,
+ route: route,
+ page: 'booking'
+ });
+ } catch (error) {
+ console.error('Error loading booking page:', error);
+ res.status(500).render('error', {
+ title: 'Ошибка',
+ message: 'Произошла ошибка при загрузке страницы бронирования.',
+ page: 'error'
+ });
+ }
+});
+
module.exports = router;
\ No newline at end of file
diff --git a/src/routes/settings.js b/src/routes/settings.js
index ae7453a..e11fdf8 100644
--- a/src/routes/settings.js
+++ b/src/routes/settings.js
@@ -1,8 +1,18 @@
import express from 'express';
import SiteSettingsHelper from '../helpers/site-settings.js';
+import fs from 'fs/promises';
+import path from 'path';
+import { fileURLToPath } from 'url';
+import { dirname } from 'path';
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
const router = express.Router();
+const STYLES_CONFIG_PATH = path.join(__dirname, '../../config/styles.json');
+const CUSTOM_CSS_PATH = path.join(__dirname, '../../public/css/custom-styles.css');
+
/**
* Динамический CSS на основе настроек сайта
*/
@@ -19,6 +29,86 @@ router.get('/dynamic-styles.css', async (req, res) => {
}
});
+// Загрузка настроек стилей для продвинутого редактора
+router.get('/api/settings/styles', async (req, res) => {
+ try {
+ let styles = {};
+
+ try {
+ const data = await fs.readFile(STYLES_CONFIG_PATH, 'utf8');
+ styles = JSON.parse(data);
+ } catch (error) {
+ // Файл не существует, создаем дефолтные настройки
+ styles = {
+ 'primary-color': '#ff6b6b',
+ 'secondary-color': '#38C172',
+ 'background-color': '#f8f9fa',
+ 'text-color': '#333333',
+ 'primary-font': "'Inter', sans-serif",
+ 'base-font-size': '16px',
+ 'line-height': '1.6',
+ 'max-width': '1200px',
+ 'section-padding': '60px',
+ 'border-radius': '8px',
+ 'transition-speed': '0.3s',
+ 'hover-effect': 'lift'
+ };
+ }
+
+ res.json({ success: true, styles });
+ } catch (error) {
+ console.error('Ошибка загрузки стилей:', error);
+ res.status(500).json({ success: false, error: 'Ошибка загрузки стилей' });
+ }
+});
+
+// Сохранение настроек стилей
+router.post('/api/settings/styles', async (req, res) => {
+ try {
+ const { styles, css } = req.body;
+
+ if (!styles || !css) {
+ return res.status(400).json({ success: false, error: 'Отсутствуют данные стилей' });
+ }
+
+ // Создаем директории если не существуют
+ await fs.mkdir(path.dirname(STYLES_CONFIG_PATH), { recursive: true });
+ await fs.mkdir(path.dirname(CUSTOM_CSS_PATH), { recursive: true });
+
+ // Сохраняем конфигурацию стилей
+ await fs.writeFile(STYLES_CONFIG_PATH, JSON.stringify(styles, null, 2));
+
+ // Сохраняем CSS файл
+ const cssContent = `/* Автоматически сгенерированные стили - ${new Date().toISOString()} */\n\n${css}`;
+ await fs.writeFile(CUSTOM_CSS_PATH, cssContent);
+
+ res.json({ success: true, message: 'Стили успешно сохранены' });
+ } catch (error) {
+ console.error('Ошибка сохранения стилей:', error);
+ res.status(500).json({ success: false, error: 'Ошибка сохранения стилей' });
+ }
+});
+
+// Получение текущего CSS
+router.get('/api/settings/styles/css', async (req, res) => {
+ try {
+ let css = '';
+
+ try {
+ css = await fs.readFile(CUSTOM_CSS_PATH, 'utf8');
+ } catch (error) {
+ // Файл не существует, возвращаем пустой CSS
+ css = '/* Пользовательские стили не найдены */';
+ }
+
+ res.setHeader('Content-Type', 'text/css');
+ res.send(css);
+ } catch (error) {
+ console.error('Ошибка загрузки CSS:', error);
+ res.status(500).send('/* Ошибка загрузки стилей */');
+ }
+});
+
/**
* API для получения настроек сайта
*/
diff --git a/src/routes/test.js b/src/routes/test.js
new file mode 100644
index 0000000..bdbaa3c
--- /dev/null
+++ b/src/routes/test.js
@@ -0,0 +1,14 @@
+// Простой тест для проверки AdminJS
+import express from 'express';
+const router = express.Router();
+
+// Тест API роут для проверки админ панели
+router.get('/test-admin', (req, res) => {
+ res.json({
+ message: 'Admin panel test',
+ timestamp: new Date().toISOString(),
+ session: req.session ? 'exists' : 'missing'
+ });
+});
+
+export default router;
\ No newline at end of file
diff --git a/views/guides/index.ejs b/views/guides/index.ejs
index 4e104d4..ab989ee 100644
--- a/views/guides/index.ejs
+++ b/views/guides/index.ejs
@@ -6,6 +6,24 @@
+
+
+
diff --git a/views/index.ejs b/views/index.ejs
index df70552..3dc7e4c 100644
--- a/views/index.ejs
+++ b/views/index.ejs
@@ -54,26 +54,23 @@
Найдите своё идеальное корейское приключение
-
-
-
-
- Тип тура
- Городские туры
- Горные походы
- Рыбалка
-
-
-
-
-
-
-
- Найти туры
-
-
+
+
+
+
+
+
+
+
+
+
+
Доступные туры
+
+ Очистить
+
-
+
+
diff --git a/views/layout.ejs b/views/layout.ejs
index 6a8ecd2..164e4b1 100644
--- a/views/layout.ejs
+++ b/views/layout.ejs
@@ -31,6 +31,8 @@
+
+
@@ -89,6 +91,11 @@
Гиды
+
+
+ Календарь туров
+
+
Статьи
@@ -197,6 +204,11 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/views/routes/detail.ejs b/views/routes/detail.ejs
index d3512e8..fa6c79a 100644
--- a/views/routes/detail.ejs
+++ b/views/routes/detail.ejs
@@ -41,7 +41,7 @@
Максимум участников
<%= route.max_group_size %> человек
-
+
Забронировать