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 ( +
+
+

Календарь рабочих дней гидов

+ +
+ + +
+ +
+ +

+ {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()} +

+ +
+
+ + {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.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} активных гидов +
+
+
+ + {/* Фильтр по гиду */} +
+ + + {selectedGuide && ( + + )} +
+ + {/* Навигация по месяцам */} +
+ +

+ {monthNames[currentDate.getMonth()]} {currentDate.getFullYear()} +

+ +
+ + {/* Календарная сетка */} +
+ {/* Заголовки дней недели */} +
+ {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} +
+ )} +
+ ); + })} + + )} +
+ ); + })} +
+
+ + {/* Легенда */} +
+
+
+ Городские туры +
+
+
+ Горные туры +
+
+
+ Морская рыбалка +
+
+
+ Рабочий день +
+
+ + {/* Быстрые действия */} +
+

Быстрые действия:

+
+ + + +
+
+
+ ); +}; + +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.showGuideFilter ? ` +
+ Гиды: +
+
+ ` : ''} +
+ +
+ + ${this.showLegend ? ` +
+
+
+ Доступен +
+
+
+ Выходной +
+
+
+ Занят +
+ ${this.mode === 'booking' ? ` +
+
+ Выбранная дата +
+ ` : ''} +
+ ` : ''} +
+ `; + + 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 => ` +
+ +
+ `).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.showAvailabilityOnly ? ` +
+ + +
+ ` : ''} + +
+ +
+
+ +
+
Загрузка гидов...
+
+ + ${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 ` +
+
+
+

${guide.name}

+

${specializations[guide.specialization] || guide.specialization}

+
+ + ${status === 'working' ? 'Доступен' : status === 'busy' ? 'Занят' : 'Выходной'} + +
+ + ${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
+ +
+ + + + + +
+ + +
+ + +
+ + +
+
+
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ +
+
+ + + + + +
+ + + + \ 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

+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+

📊 Результаты теста:

+
+

⏳ Загрузка и инициализация скрипта...

+
+
+ + + + \ 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 @@ + + + + + + Универсальный медиа-менеджер + + + +
+ +
+
+ 📁 + Медиа-менеджер +
+
+ + +
+
+ + 📤 + Загрузить файлы +
+ + + +
+ + +
+
+ + +
+ 0 файлов выбрано + + +
+ + +
+ +
+
    +
  • + 📁 Все файлы +
  • +
  • + 📁 Маршруты +
  • +
  • + 👥 Гиды +
  • +
  • + 📰 Статьи +
  • +
  • + 🗂️ Общие +
  • +
+
+ + + +
+ + +
+
+ Загрузка... +
+
+ 0 файлов + 0 KB +
+
+ + +
+ 📤 Отпустите файлы для загрузки +
+ + +
+ +
+
+ + + + \ 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

+ +
+
Новая функция: Календарь гидов!
+

Теперь доступен календарь работы гидов с отображением расписания, выходных дней и загруженности. + Поиск туров учитывает доступность гидов на конкретную дату.

+
+ +
+ +
🎨
+
Редактор стилей
+
Настройка цветов, шрифтов и внешнего вида сайта. Профессиональный редактор с live preview.
+
+ + +
🖼️
+
Менеджер изображений
+
Управление изображениями сайта. Загрузка, удаление, просмотр галереи.
+
+ + +
📅
+
Планировщик смен
+
Планирование рабочих дней гидов на месяц. Массовое управление расписанием.
+
+ +
+
+
+
📅
+ Календарь гидов +
+ ↗ Полный вид +
+ +
+ + + +
📊
+
Управление данными
+
Используйте боковое меню для управления маршрутами, гидами, статьями и другими данными.
+
+
+ +
+
+
-
+
Маршруты
+
+
+
-
+
Гиды
+
+
+
-
+
Статьи
+
+
+
-
+
Заказы
+
+
+
+ + + + + `; + 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; + } + }, + + + + + + Календарь рабочих дней гидов - Админ панель + + + +
+ +
+

📅 Календарь рабочих дней гидов

+
+
+
0
+
рабочих дней
+
+
+
0
+
активных гидов
+
+
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ +

Загрузка...

+ +
+ + +
+
+
ПН
+
ВТ
+
СР
+
ЧТ
+
ПТ
+
СБ
+
ВС
+
+
+
Загрузка календаря...
+
+
+ + +
+

Легенда:

+
+
+
+ Городские туры +
+
+
+ Горные туры +
+
+
+ Морская рыбалка +
+
+
+ Рабочий день +
+
+
+ + +
+

Быстрые действия:

+
+ + + +
+
+
+ + + +`; + 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

+ +
+
Новая функция: Календарь гидов!
+

Теперь доступен календарь работы гидов с отображением расписания, выходных дней и загруженности. + Поиск туров учитывает доступность гидов на конкретную дату.

+
+ +
+ +
🎨
+
Редактор стилей
+
Настройка цветов, шрифтов и внешнего вида сайта. Профессиональный редактор с live preview.
+
+ + +
🖼️
+
Менеджер изображений
+
Управление изображениями сайта. Загрузка, удаление, просмотр галереи.
+
+ + +
📅
+
Планировщик смен
+
Планирование рабочих дней гидов на месяц. Массовое управление расписанием.
+
+ +
+
+
+
📅
+ Календарь гидов +
+ ↗ Полный вид +
+ +
+ + + +
📊
+
Управление данными
+
Используйте боковое меню для управления маршрутами, гидами, статьями и другими данными.
+
+
+ +
+
+
-
+
Маршруты
+
+
+
-
+
Гиды
+
+
+
-
+
Статьи
+
+
+
-
+
Заказы
+
+
+
+ + + + + `; + 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 @@ Гиды +
- + Забронировать