From 13c752b93a26b3c9697b73f2418e2d5f09c305d2 Mon Sep 17 00:00:00 2001 From: "Andrey K. Choi" Date: Sun, 30 Nov 2025 21:57:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=9E=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BD=D0=B0=D0=B2=D0=B8=D0=B3?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D0=B8=20AdminJS=20=D0=B2=20=D0=BB=D0=BE?= =?UTF-8?q?=D0=B3=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20=D0=B3=D1=80?= =?UTF-8?q?=D1=83=D0=BF=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование - Удалены дублирующие настройки navigation для чистой группировки - Добавлены CSS стили для визуального отображения иерархии с отступами - Добавлены эмодзи-иконки для каждого типа ресурсов через CSS - Улучшена навигация с правильной вложенностью элементов --- CALENDAR_GUIDE.md | 111 ++ config/styles.json | 6 + database/guide-schedules-migration.sql | 22 + database/guide-working-days-migration.sql | 22 + database/init-database.js | 46 +- docs/SCHEDULE_MANAGER.md | 117 ++ public/admin-calendar-component.html | 462 +++++ public/admin-calendar-full.html | 55 + public/components/admin-calendar-resource.jsx | 223 +++ public/components/availability-checker.js | 306 +++ public/components/guide-calendar-view.jsx | 373 ++++ public/components/guide-calendar-widget.js | 633 +++++++ public/components/guide-schedule-manager.js | 865 +++++++++ public/components/guide-selector.js | 639 +++++++ public/css/admin-custom.css | 101 + public/css/custom-styles.css | 3 + public/guide-calendar.html | 557 ++++++ public/image-manager.html | 953 ++++++++++ public/images/logo_dark.png | Bin 0 -> 14432 bytes public/images/logo_white.png | Bin 0 -> 14315 bytes public/js/admin-image-selector-fixed.js | 21 +- public/js/main.js | 201 +- .../js/universal-media-manager-integration.js | 477 +++++ public/professional-style-editor.html | 1010 ++++++++++ public/schedule-manager.html | 282 +++ public/style-editor-advanced.html | 1013 ++++++++++ public/test-image-editor-debug.html | 143 ++ public/tours-calendar.html | 625 ++++++ public/universal-media-manager.html | 909 +++++++++ src/app.js | 8 + src/components/admin-calendar-page.js | 490 +++++ src/components/index.js | 21 + src/config/adminjs-simple.js | 360 +++- src/config/adminjs-simple.js.backup | 1681 +++++++++++++++++ src/routes/admin-calendar.js | 35 + src/routes/admin-tools.js | 84 + src/routes/api.js | 344 +++- src/routes/guide-schedules.js | 303 +++ src/routes/images.js | 237 ++- src/routes/routes.js | 38 + src/routes/settings.js | 90 + src/routes/test.js | 14 + views/guides/index.ejs | 18 + views/index.ejs | 35 +- views/layout.ejs | 12 + views/routes/booking.ejs | 262 +++ views/routes/detail.ejs | 2 +- 47 files changed, 14148 insertions(+), 61 deletions(-) create mode 100644 CALENDAR_GUIDE.md create mode 100644 config/styles.json create mode 100644 database/guide-schedules-migration.sql create mode 100644 database/guide-working-days-migration.sql create mode 100644 docs/SCHEDULE_MANAGER.md create mode 100644 public/admin-calendar-component.html create mode 100644 public/admin-calendar-full.html create mode 100644 public/components/admin-calendar-resource.jsx create mode 100644 public/components/availability-checker.js create mode 100644 public/components/guide-calendar-view.jsx create mode 100644 public/components/guide-calendar-widget.js create mode 100644 public/components/guide-schedule-manager.js create mode 100644 public/components/guide-selector.js create mode 100644 public/css/custom-styles.css create mode 100644 public/guide-calendar.html create mode 100644 public/image-manager.html create mode 100644 public/images/logo_dark.png create mode 100644 public/images/logo_white.png create mode 100644 public/js/universal-media-manager-integration.js create mode 100644 public/professional-style-editor.html create mode 100644 public/schedule-manager.html create mode 100644 public/style-editor-advanced.html create mode 100644 public/test-image-editor-debug.html create mode 100644 public/tours-calendar.html create mode 100644 public/universal-media-manager.html create mode 100644 src/components/admin-calendar-page.js create mode 100644 src/components/index.js create mode 100644 src/config/adminjs-simple.js.backup create mode 100644 src/routes/admin-calendar.js create mode 100644 src/routes/admin-tools.js create mode 100644 src/routes/guide-schedules.js create mode 100644 src/routes/test.js create mode 100644 views/routes/booking.ejs 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 0000000000000000000000000000000000000000..6b65081969bd13c90bb11aa3e64cc4ea84727020 GIT binary patch literal 14432 zcmeHubySpX*Ds2Mlp-SCF#^&9IHWX4Nh3Jm&^`1J0xB>xNH<8AfRY1)Al)EHNVoLR zA?3Mw-sgMY?>({Bx6b+Robv~9?R)Qi?fC7OdqL<6bwy$VY62`QEMjFPIV~)#TSqrv z__qOyT(#0H@Z+wN(o0t?ERyz{uUiRRBs5r9cgAdV4BQOVR7K6;4m>b(xTyt?r-Ku~ zjfEvH<>>@7v$JqxG_|m@ag+eecXSo?lmPvuR}}dDhRh2Bm|V;)MYZG<{$>GA5+G|gHz!eE zUIYTcgAn9_yIAq^Lm&`dJ^@|<0d9bU+ttg_4d%)1=*j|UAYlAUhn$70nTw5+n+@EN z@kS@i6z=XO0Rj;){;gWt&CSKe^q<-tU3p-CQQU7tcmSt)Vdk#9{5*U&W-&5~|ASvt z+T8W#_>W7pEu8;4{NrSA^VdyIFc((~9WN&f36PeBE8N}1%;GN%H$s0ak#(_vxmlP? z3h)UCar5(Y3-XEc{&$aV?)aC0yr-M8xuh9HP((lo0_GOt7cu1q3&AY7P5EIW+%TA# zslZb+0SNf1$UhbRL*(DpD3}6Tgam{HgoPmdV15yZ(9?h2{MW;O=huO|n_1tuA}R1s zz5k;9#VgKx6J2|oe+1>P!{1T)r^G)(_7BGYl$QT`s?5#)nIumXWNs$vX5(gW@vjs~+q?aHiU911+QS^JBtV|r<`$MPcY8OGq%dWJrS6qjUA)AtA&e%uC$yzzlD>4hr1P{sSC`}TteoCqA6pg@>jPpS=n&T>|6+bRw!l&Iv3mL4IX9X&uk>?O7X7-LbE@dm_(p zWn_K@vLaPK+!f#-`pEbq>@l-7dH#zphISRY6~+}iuEi%daJXIYv|){2mgv|Q7u|>Q zPFOOOZSi-N@bAki;VXBiKa0J1#{rpK+ZNpZIw85$*nCg2N0Mvn(!z7da!1IM+ZRP4 zC&!v3okPgT%)G-t6B@$W?gV0vz2BA*q9j9vU}nDi|B3(C1q3YTc1up+VpXee=P`n^#r= zkQnf$lAK((mRe46L9vcTm$)ZtQQbVfxhZ$#^{=P>S(Zv1tYS#980TKDFV5)aX%|T> z{No>Nm23e~*{@%2MGkN}WEzIUWQmKBD;-WC0UaNZv2n!+I~YH@Tp*7+mZB}A+uG|w z)1Y949Yo%@Mx#Zn9JZ*=`pp$yTK9Vn!RHZwo&O;Zw@~9Hr~#Kb`o~mMrm=aHo{g{{ zk?j^gH%++JvW`%yArVJZ-a^gMqVcfMeXk-ZsnDNFJ~SeAONGtyS6&i?%%M&ONF~Z? zny{Dnv`9tFc-g~P5+E$eIWBK%y55~o^>nNKD5H)g1Za8sQgTMY*gNp#PevT96o7Y1 zYGr3#!5%f+>vH?f=_TX!jIlUFFW4S@x)v8jkDdn1py(>#e0hwqw|8ek<-#J{hcH!k zh4cBYF)^R~rCA9FT|p0u>&bE0e(Jr&(6d4dN1lq|nCwVT;K*{ah;)e|m|LPUWMEX+ z*S7{I#T99u8Sv3J{P>XCeFd}u)$Fa?nRRAU7w!Dw4hh?>KoG;YSz}RUKl6~Dd1!%7`&J<^w~D_c~!f?j4zq-pR*})DTekgMoDUxTxaM* zW|{jB@(!4?i^@sYsGXOin@R?ErGPaysMdd(5~jjo_pge8FA zzHn}bvkmyzS|+-6h=OBZNkhi=_Q->UwbErD$mTS4W791k0{IE)K~ogjt#1tU`Y5l8 zqDnihFxghw51ApOck)0?{CdlSLe3u_W;u4vF%P?cumcJL>qBCQ$(7vVc@la*sqWQ| zIE1A5Znu(*_X8OudMZsl)$lV1LmDEd*4T|B4=1Xg?A9|IoLX|AHu|2tsbxAe}W>Lo3QxAVz?Z@M)(rA)bdgBe9xGvLe1;9g!|qkyfPh_X&+cjBkMk92FE zY6r)SJr?T--J}l#bYE@rc4W$^(w6SULgKDgk00|W8sQmE509`nPzw+P(8^Ded_jUB zv$gF28Oc*f7XZVqp>zJ8D~L-q1gz!CH6{9e?yd=3PO%6)SJ?;Po*2M=xt9$c(zY7! z*#u6t`^T@?4s=Ov=Tj5<<-n=z>%59DXywwWo^G*8JIz*Ek_h4AGfaw4D09;|0lYWO z?d>C@Mc(+H`#K+cyVBSWWmn?LTl{oKndlESg|!u<>WN=|oY^>K6RFZJZa+)Lx#H@{TjgDFoHz7CLxk-TDy2Jk^fMgJB6 zC_5t?eXc@D)&qUx(73Up5@`$Krm~h4QHNfXUruYsfS6|C+DY>x_M)JRBy@dyygdvR z_qxYfDYTh(9XknWxW3*#9QPO!6-yAW_i^j}$+27zlZ5i(IXcVkkMVdQE)zsOpSa7s zTNo*J_d`rXzh*aWo=aQ+@-|idADH8&?iMYM%wE~aj3TMS;HPG(F?!ge*8?}7t>7g*$3n&L?S6UXOVSw!P)SH&C zc-^8%F$nL|`-cS!=gJ{^X|4FLnv~~#e>PTCRYdhXOk(+S|KKF|%j7z><;v7_hu z+8cZSpz(};@L*J1tIY}2)lVHQFBtQnV$Nla=}i+hUi7+_f)k@D3+bbXko$qglR{6# z??~UmdSMiOk>!jU(w7=(RPA~!IasqduD~uf8MLVGv47f@lVSkY_Ej*GC6kA_ockK0AvY+_ODwvI+gGTWXPw*7?y|dzYsL`M9K?FR%Z(=>4FjL_mb7bROTEIZ2)2>{APKii3`@!tt_uj?eb$`ckPYTBbk5?abei!5ocSlsF7Mc zU*FG@A4HWN5g|4~!VkC}6%Eyv+qEO%pS=nX)9&0kGxH|-r7CCpK2D-2HFHaNF*DQc zx8`RdHSRN)I?b`0Tiss10qZ^aHfo<>Nv?ag$7Mgewt^<24|Lv079=I>GVOj168rMw zjL6d~yH0-|F&wyAxDib20K(&+xl77q;7eFwUJMrVez5xnd6lo;r1Dk@$opk6rQ_iv zUD=j*lv#`P%cHe;Gt*-GR1;h=0hAc}feF02*$|6EomBF~#o3{ZtTzwHk!clOGd-I| zb#{*4Q8a-*>LtByAJxRZ^H@X4>v@srwNwa6X(A)ITQ3be-}6F{R$^nth_!Pmk%o_j z!q`~-WFg0SgHnK!0^QMw&afjdw6(uI~F%#}-#fz5~4R!dtZ!nj!DJW8u zD=E8`=iD>)M;9e+KJ{+$vD|ydoWeIFp``Et*m1@Mx_bpZuc7gJPbdAI7!!xO zfKYro1XBjD6ts02CFNEN!F*H;&Yr4^UT~<;M=fGVk>^VnBFmk{dNjaxQ5N|VHQRF? zOw<_*F_1K|=%Zk|yyAn9O%upXWVic-^mdPRnb7Fm?FnAwX`TU#yA_rZHw!y<8PR0S z$BJ;_-23!-E15*Iz%mUy1G1V*mDX`3L6guJ5cdQv^3?mlJ91L=Ot{Ow9qx+yN4zqB ztT)emU0hs}u5VNO`W5l!+>>-SOCoy8SN>@0Hs~bh={`eQBbB?{RL${x1+P&3gvT zSoZgxvvxs#FUzP72t!f##3RrZL$ztAOnmh?NVGkYxFfgpLtM$F?UmQ%h8nHpFY~nk zTv!k7Ouf>n0}Py$@E*(Gm9EFZq3_)(nZfBdJ#0Nxwnf(yJ}y)XqiETtKV&+-{_Ny-V z1PmAR$qM(U{Xd?6I?Ra1@6xs-rP+9M&r*=B@5!Qio(K|6qoUFtC-rQBFDR+0yX$bb zVbsfo_Ik@N!MAf(T>)6^a&PuP|9%C=u=*z(|^DH5Hjj{y3NnT9}wGT=pvDd_jsWqYe!BI;S zyt!3<`GZMAU8haUTKCwKQs3^~9~BBI4^XmdbVUfB&K9I^)6|;I9+tCTq#C)Ydc=Jq zp#v*fpm~@fd^58>zICugx1cTF#$f$%^wjTEqtBkTr5Qi>rn*EuF!vAPX38s~Nc6Np zl@8OSSi@(Gm;KbD%bF<22g)M6D<_{21&1{cvyjs9G~hF5@XyS)h2a>8_Z^r*u`|!G zN$A{b4imzU)jY6#2yVSbWH;@af_Qlo+IB)%zd0eW!!4Jhf)XkExb}Zm4Q5q2kI@n8 z4)%@9&G}*dY`FLJYeRFA#8Uy;5i?ZUEODIEZdoD)g055BfBbc6@g&)2(|4sAMJ15n zrDd!azK>QD8k#dC9+;KUo5`@^A<@N=DqYddR$lvubY}xx)aff9Ak_DW*I(W9IgjW} zUU~6?mv=CQsfT}NfQ$c;#-}fhC<_YXHyQzhiVDj;KK$Ju=KnMW|90SdrV&jFFA1CG zkDWn<^yIb5e#YGDv&b4A(fEVEq3oEljTO7R zH)=Q|`Pz}(ixG+5c#`hubXF z0W?aQBU%|LV`G%>yMyPJ2ag@E@m`AZlN$J{G;@u)pRLYUZ-}$e&zt0^>3d88FH$3Z zLIlGYxUc;Tsz#ykpLmA#luT{F$}R&0eszEBiaw{d8a8WEGwH(h_#0}2Kk4Y?Bn|Xq z;^g+LZQ=6RCQ7=6$dVFA@CZ7bHbAAw40PcGk#n)#|}ANHZ&F(Q4;+I311BmLs7 zFm$pwr+4%A)FJm{PQmKrQV)79zj#r$={+qs;S{k6A7Lc=Wkf{n4DJGaH@8|QZ4jOw z);~PuW+62#*0U{cu0walac#yddOLTCh*#c zE7kovPHb_l^d;Ow)PrEmy_UMV+U9dse(u31aqML9_)pOY0a(O3ff6ke?fI_F=~V5i z{PJ3;y4LQ;@9n$Y&xZ@Rf`1A!be_eeG-4A_EGsZFb*?{;Af*cXk}F3c{hn{8!^j&O zN&NL>=bZcT=sL{IiE*6g2OxEH^m_VjjSBWEgIuuP+HKLie6`;cdgJ^lDg|p{{nq^pgSmLHHt1Y!gNW^U_e%VJ?Dqy#;^IhFs;gHFaMICJb3upl<_xsQ9Y%t4<-^?R}-(MpE} zM>Gx-=3*vTl5=N6KEzYQh>tlgVDRKLDwh!N`SGV;VU5_mpi8xQZ`EJ1`&0r_I*h=l zZ03P=SCtw+Lfb)`?}bSe^j{TMUIgS`bW#cm2s*hw$Ku!z3FTXw?I0j&KO-8J|FB4j zX}yET%yvCh$KaoUNhiDai0%XgY}9Y0eYB8A?fidy99 zua@+m_n=cSs}lC*$*Pc+Qs;Uis~v78<+8|pvL!BXcx=4NPc`&%S&(OTdrdj!7jVnb ztUfd@16M$Cxe#IZJ(?@sD5<)wCCyjud`L+_C%I|Yj{aSn_|nGh;|?bZv!CDXRPgKu z7Z*tN#Ui3x;-tR4&Th_vQhX}kt#g3Kw4Gj@h)use0OpCHxIIY^c%$|!>og_C)ST7i z!utn_zK=>ohu}SepU*m5TXNM0?Y1#3#`#x`;Qg8r%+6lpm_+@?-u@%#9jcH;p3Y>+ zMa)7Uho&A;UH;?_D+UC)Bllv!PzK=kq#M8Mhvw(K%H&cWibw#Q_ZR#fV4E>yJnljR zz~}1n3V9c|_l**@oOF}|JkXM5t(~>C@M3YFAv3-iob|LP<$)I;S}2n+E<%RZ4v6EP zTW@6*ccxTE{6OklyywUL0Pvp*EJG+Uw_iyVHEi9s-OAXt(qg|IY@-!ZdXi-8YMH5b zQ2@H7&_`amE&8{&X3996YtqK;)0-x0$i3@~Xx+m)wp_sg0^mC1 z>(+#?>HUY%b`BE^lm6d>9l~7U>{ch!&*lZa?(MHD_GX%ec$1Ax)gLA`@426d6HA$x z7h3B^sd#&TfSgZBLQ>baU9Ry%%V{7PUJUU# z$qTWWw0s#UV0}NapA_1T6=o*jC)vG!282-{_P)TYB(@fFdfG(N-0g>0cW|CUcpV6` zLSIfymx{9sveC#6?1w&{S!kiJ<0!y0MTI-Nd-sg_;%`$dL_cwj=tMME%UYLN=lRC>wInl5_j46XL`aBbykoD0>dSoDtR4P}e!GfTuET8bobhlz)6v-~8OFdHKW|&2J|pJArPNlag3*K+=aC}0! zQYcxAX}gzo>n&)Uk^khNW8Eo2t0ALM#e;&AVM%ln$RjrWjcFo^mP#T-2*FKEXW{f- zn*PF8z^8|DL9lU8Pp?p(zV?UrOd<)Y?A{mO`qx^+TtdYtJo2RZMIIBdEhgwKeAgUJ zU;diFodNx+p96p-D+FB@@h#cP99f#O(gy6S&Mar$eTkO=y@kW-)OMa6&%J%hGU-rX zpY<(ULHV6;rnemtLWIvX{UzqvfHJ?R8B2^?#$x{C;v$(kgMoEbsP?eJKKx=MHmlp}En>j#EmB#Yn!7%I*1xwt8_90kmD+{2o`2VYhr`ruvp zql0@nxhj8bxA~abp4{W?b|*q;FK?SoKkPD(UGA@p+=(G8vU>p^ABQEzq*c9?oYA#EG~nH%@b)w7wu%fs zE9)(6+z$Zy|}ugHaq-)Yk6*5W?obSBQVDS5BH`Eqdg(k~F5T{#jTzwSYb zYt+!WE}7CB-U2@vH#@dWh_6)>Qi~T;jUjcUc=w29hS)-#a8#A+eT}Q2_Sjbybkc+V zUm2oCBh|dcjYY?nNB!*`$pVQFxusEQ-OIy8M0MeOKlgu3={_`XhE(N(ckA=W)$F(H zGQJ%32THci%0V_ZV;BCqm;KC77Ne!3gKHw5Cv8^;uKzl(;4-or>4L_uCR%jNaX#u9 z<+3Ie0YRZ+bv_&|_D}48p*l-!%Vwvh#=t5O6CcS5hQ$NX&aG|ham7Q~%TtJ_-F~i; zq*4woD2f1YOZ?OOiYOYZBPX`@254Xl!8q=u%^+wg$feA_Meg-hoe0eBO&T>shu^p^ z{2AeWOy}b7-E&`Bd+#Gns!iGnO#YoJ7`nGN?a!)^kRdw%xWwib{2}(7EKS?Xfd^Eu znSM2^!HwzYV$Po{;lKDmNW<$VBOn^^#?<)Z{Ekk`jR0LBguilD9TOW>J33I%z~-?u zRgIGnr&SVHG`{l4Z$dRczGK@g3SnEG9*++RmNObG)X`2NDoy;{-?`p<1W>Z^YVTXl zjop9Y$u^}^adU2sF`J!VOWrefd(5~di92Q54;rkco z<~%l+G7Jt5L@L$6>`0kZW+|g#^mu301Lyh|uQU3+!bgpQt5vFT0_iK@K~Tz(nn4xY9b_G1 z=MyYoh9_s4>gO~HiN6WcDtFUKBbY5;sD#RUpOe>xTLxq{v2AK$G;xuOei796zHRc5!3yo_~+x~4*sxtxGUwbSGujRVDGu$RqM1-+lv zJ&RC4vE0c!)+gx0qGIPau5X*+x$O>Ial7*e$c)w7QuXFJpKzOMJyj?7pKvaGL^6-r z2X7Cc^6tdlFCBh^Ney!f-dK<0e<>*;2-E;fbPCDdr>xIfDnl9S6g>QQgDf(#2Svi- zOJeX5l#vkCe#DMa9oI9yK(YSSeQO;gkZ-2RjROTV^iH`9s_oU^?af-L&^mGe=wz>g zoPz5+he2e_>nZ&A7fED;f`NlTr55=zfaNom8CcP9Tf+S<4^r#&^~bo4G{H{6+4$k# zWriYbJTI}>!y;a&H{4XYtNOg3s_vNMg!3eWpZ&OM3JdbFq~6*f8j8o0yGlZ~YQf3V z3Ba{%&iqBCiedfm4#B0o0-ti;OThET^NsWSQrG{*^B;8GWj#msqn zaJ1c)V_oRfa5#{n^SbjgR~``bNp9^BgG=G@7vf0 z01&LC7(O+>+ML`QCW1C^DccM4fNAnGLc$++ye<2mq1v7lTp63_mfdE#Fz0#sMcQH& zkcLOV=37X@or0y8Oyg5FJxH%>w?~Uj>%xnSY*NM@fV}z_UyX7BodZztc-b|=sLCM- zc15o*SZ39^JbWkds}R;ANkH;ANLw#^b!(O|;Y_{)QoxVU6|Q|;#N7YxUkMWAlz7fG}uhBW0+Km-LHdfzo93ln8!Qjr0|9YV+} z9{;^$J*si$^1j+x&Y3m9M%4>Pk7t2msz5OS9;P+7?+~Oe9iR56Dt^+?sN-7S1G2;1 zpU2QCWE4R^=U3xC-M^X6zlomY-=}-aAxz!?ZLvUy9UXB2jmvFlK+szFy_yLMDjlqd zYJ150WOQ+vXX1r+uEo~T4=Ww5O&7L-7n;Rc5``UHx;rO1%!OgcQiQL{Z)F-W@D=Bh z&>1D&8OSWxq}pi!d#FYBa=9%}B6 z3L#>mGHjc}nIE^(VLg$nBf>HWal*qQB*kF8X8MbS!hRcskHGp2037QP^USSpGJ#_M zOQsir(>UK;>f0V}{L$9aEsND6&-$@QM*PM>4&@RI`4Qv)#iTH7OAKXF85>8fJJKnW zkRUdF*O^+1>G>yA9DHFxKZB8$vNFTGOPG; z6X=86yoTDjl>~GW#kn9FUDoe_O;h6ttFiin&dCQ3)4B3EQw>~aw@jLhs|p~~Tq2tj zyyJ-Mv`*{uc7=o^hUU>0GiqTagsPUF;Q}zEduvPsjD7S$gHL^sB;W5{cTD;n(+wDH zBiz!q;5pKD1$d6>zt8Gc&wJ642 z_Rg)oi3{VX0WC}0Qt0_O&C!`}ull{pDAz*k*{2E`ekLz%$~@|)yxBJ9rjHZSrdC$I zkyp)X;F>U9yMI<{ubJaKE%km6)wNo9PjpYSO3Z9c7bl!{zAYQr`dKy-t6%(1W~) z+B}W6C~17Gya?MIR?8JkkB&2H%EQ^MTkn4c%&+RWxDM~_m-Br^O1{6nXCae!(HzS) zMbE5M(h+!h>z(3OKGzRsy>naJzAeA zZ0ym(!YbeG_}*Uk>-W2}@fd;+uk18-Sa>I!6vu?ZwpK3lpZ9_NL+36(r*vRPD)!Ow z>?zo+ypZs&7{L?uQBNPYG9srSd#b=!8+TZElH9Qmp%(p^k~HUUV6u&<_3`+@a$4da zwmaKMdVW3OJ8-zV#bz@kt*Z<1>0GsTY1(}onwvqB7h6(#B?;mAeH`w6WFxT`DER1c zXDOs^-6Vkc>W(1=Hvq7B%=uOYD@T8Bky7(@L7Y`FAQ zzR`XcmGH#(Qq9?O5@UZ{{51>Lzcu$|FMIn*V^8>=xJ&G34K7^4XA4?=Rps9b11)vR zNGaUCMSf5%A0{nEwN?^pb{mv+?fSz#c2 zzqs{=Np&Y0It-1oxzR_#`p3(EPSm6rH;!IPT*hCYI465-E-_~X`?15$JlUOgTdI|zC=SWP1ykfom5%u zh)3}QYvu-v@52d+HuKp9bj@`fUD)Lxjc|c^@Ndn33CVeRv|S(u!o!GrYgK$*t6NJ9 zrT%FYTVm&iX-uq`D9;fcg4m@BW4`~&cz01XGRpfBPI0!=xTE0kPl;8#H5&(z7fPmESsgS@x6Ce4|L=QT-mv{EI=JyA8jm zzWw=&P)Xr*%&$)KJ;y_ zK~lr#ieX731lXdzt?GA&#&XFItFTYObNcMX`XaN=u6qaxdWi#v$f4m)HN&%*rk@pJ zpi;ZFZ*A1tD=1otOXB&15`N=V%RBTe96m*U2Xy2~)|&4muU-{j30XAG-?9_lI#H&E zjB@O@RBU|bkEMHeWAV!%K~hKE1WwKXjWC~{{U3v@+(8#$NyV#JT{qTlm`TQ`wO=-! zNp7;4lCO47^=wkEn^X~M!*RS2A53Nm&hCZUjHj|!K3pbwJYiJ6zU?S?ej{mK`}yg& zjtaKO0WH%;dhcEvF$#=!%hW=l?dR^^=U;zk(_bU~uHX2+6t_z?_WY4TqCL{!@D48> zQaIoADHsE2x7oZyPG=#ncSjnPn^I1decf~589yrjCF5vvoUv=>Lt$H2&Duv}e+{Y2 zg2Xby?c%eGuJhTp=R1YGCtQNMq5FeBN^YGWk1U@;rqTD-JA%qi>QGx*M}qLh#*eTleZ+e#nzOdH z^~HZ8F~j>;4K9g7mmFZ#V8%p`DKJ1F%0mN(YHcp^=7E-KKb8_w(|MK0gi<9n;cpfY z{T-a#201C($7c*eu3^T$ptBkh+Odl3?TgrP0Y>@d${N>Z?ZqOZ{A0u;@2@#|qbX&o zBQ0i}72gA29c$cufQfwK((~LsY+Uwq^f#${wklLiM0}^efY)s$aPAuQuI)Q)crD6u zs!0*Y4j7qmzdSrkE@tt)w;Na~%X%bk`-?cFl=` zuAxoFrzG{gC;= zBBfbL4yEwpF~rgO*Yn}stwy;&=2uJTrle>In&eCv?EE;ODQT$jk-6W}r%o3x4jI{9wr~`-#(PK`j|W=?I~;BQJKObRV5{e2Hy_O{+Bk<|IhSa+t~l#jnmN|+I$ra4j1ayoBsg>{?JaYT;}!r{|1gS B1l<4t literal 0 HcmV?d00001 diff --git a/public/images/logo_white.png b/public/images/logo_white.png new file mode 100644 index 0000000000000000000000000000000000000000..ba7e59e7ea1c7f8eeab69feb92564d2365372ff8 GIT binary patch literal 14315 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 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

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

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

+
+ + + +
+
+
-
+
Маршруты
+
+
+
-
+
Гиды
+
+
+
-
+
Статьи
+
+
+
-
+
Заказы
+
+
+
+ + + + + `; + 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

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

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

+
+ + + +
+
+
-
+
Маршруты
+
+
+
-
+
Гиды
+
+
+
-
+
Статьи
+
+
+
-
+
Заказы
+
+
+
+ + + + + `; + 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 @@ Гиды +
- + Забронировать