feat: Оптимизация навигации AdminJS в логические группы
- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование - Удалены дублирующие настройки navigation для чистой группировки - Добавлены CSS стили для визуального отображения иерархии с отступами - Добавлены эмодзи-иконки для каждого типа ресурсов через CSS - Улучшена навигация с правильной вложенностью элементов
This commit is contained in:
111
CALENDAR_GUIDE.md
Normal file
111
CALENDAR_GUIDE.md
Normal file
@@ -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 как пользовательская страница
|
||||||
|
|
||||||
|
Система полностью готова к использованию и автоматически учитывает все ограничения при поиске и бронировании туров.
|
||||||
6
config/styles.json
Normal file
6
config/styles.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"primary-color": "#ff6b6b",
|
||||||
|
"secondary-color": "#38C172",
|
||||||
|
"background-color": "#f8f9fa",
|
||||||
|
"text-color": "#333333"
|
||||||
|
}
|
||||||
22
database/guide-schedules-migration.sql
Normal file
22
database/guide-schedules-migration.sql
Normal file
@@ -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 'Дополнительные заметки о рабочем дне';
|
||||||
22
database/guide-working-days-migration.sql
Normal file
22
database/guide-working-days-migration.sql
Normal file
@@ -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 'Дополнительные заметки о рабочем дне';
|
||||||
@@ -15,11 +15,55 @@ export async function initDatabase() {
|
|||||||
await db.query('SELECT 1');
|
await db.query('SELECT 1');
|
||||||
console.log('✅ Database connection successful');
|
console.log('✅ Database connection successful');
|
||||||
|
|
||||||
// 1. Create schema
|
// 1. Create schema with trigger safety
|
||||||
console.log('📋 Creating database schema...');
|
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 schemaPath = path.join(__dirname, 'schema.sql');
|
||||||
const schema = fs.readFileSync(schemaPath, 'utf8');
|
const schema = fs.readFileSync(schemaPath, 'utf8');
|
||||||
await db.query(schema);
|
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');
|
console.log('✅ Database schema created successfully');
|
||||||
|
|
||||||
// 2. Check if tables are empty (first run)
|
// 2. Check if tables are empty (first run)
|
||||||
|
|||||||
117
docs/SCHEDULE_MANAGER.md
Normal file
117
docs/SCHEDULE_MANAGER.md
Normal file
@@ -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 дней
|
||||||
|
- При проблемах с доступом перелогиниться в админке
|
||||||
462
public/admin-calendar-component.html
Normal file
462
public/admin-calendar-component.html
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Календарь гидов</title>
|
||||||
|
<style>
|
||||||
|
.calendar-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-navigation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-date {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #343a40;
|
||||||
|
min-width: 200px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guides-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: white;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox.checked {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox input[type="checkbox"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
background: white;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 8px;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.other-month {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.today {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-color: #ffeaa7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
background: #343a40;
|
||||||
|
color: white;
|
||||||
|
padding: 15px 8px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-badge {
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-badge.working {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-badge.holiday {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-badge.busy {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="calendar-container">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<div class="calendar-navigation">
|
||||||
|
<button class="nav-button" id="prevMonth">‹</button>
|
||||||
|
<span class="current-date" id="currentDate"></span>
|
||||||
|
<button class="nav-button" id="nextMonth">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="guides-filter">
|
||||||
|
<span class="filter-label">Фильтр гидов:</span>
|
||||||
|
<div id="guidesFilter"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="calendarGrid" class="calendar-grid"></div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #d4edda;"></div>
|
||||||
|
<span>Работает</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #f8d7da;"></div>
|
||||||
|
<span>Выходной</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #fff3cd;"></div>
|
||||||
|
<span>Забронирован</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
class GuideCalendar {
|
||||||
|
constructor() {
|
||||||
|
this.currentDate = new Date();
|
||||||
|
this.guides = [];
|
||||||
|
this.schedules = [];
|
||||||
|
this.holidays = [];
|
||||||
|
this.bookings = [];
|
||||||
|
this.selectedGuides = new Set();
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadData();
|
||||||
|
this.renderGuidesFilter();
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateMonthDisplay();
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
const prevBtn = document.getElementById('prevMonth');
|
||||||
|
const nextBtn = document.getElementById('nextMonth');
|
||||||
|
|
||||||
|
if (prevBtn) {
|
||||||
|
prevBtn.addEventListener('click', () => this.changeMonth(-1));
|
||||||
|
}
|
||||||
|
if (nextBtn) {
|
||||||
|
nextBtn.addEventListener('click', () => this.changeMonth(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
try {
|
||||||
|
// Загружаем гидов
|
||||||
|
const guidesResponse = await fetch('/api/guides');
|
||||||
|
const guidesData = await guidesResponse.json();
|
||||||
|
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || guidesData.guides || []);
|
||||||
|
|
||||||
|
// Загружаем расписания
|
||||||
|
const schedulesResponse = await fetch('/api/guide-schedules');
|
||||||
|
const schedulesData = await schedulesResponse.json();
|
||||||
|
this.schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData.data || schedulesData.schedules || []);
|
||||||
|
|
||||||
|
// Загружаем выходные дни
|
||||||
|
const holidaysResponse = await fetch('/api/holidays');
|
||||||
|
const holidaysData = await holidaysResponse.json();
|
||||||
|
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || holidaysData.holidays || []);
|
||||||
|
|
||||||
|
// Загружаем существующие бронирования
|
||||||
|
const bookingsResponse = await fetch('/api/bookings');
|
||||||
|
const bookingsData = await bookingsResponse.json();
|
||||||
|
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || bookingsData.bookings || []);
|
||||||
|
|
||||||
|
// По умолчанию показываем всех гидов
|
||||||
|
if (this.guides && this.guides.length > 0) {
|
||||||
|
this.guides.forEach(guide => this.selectedGuides.add(guide.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки данных:', error);
|
||||||
|
document.getElementById('calendarGrid').innerHTML =
|
||||||
|
'<div class="error">Ошибка загрузки данных календаря</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGuidesFilter() {
|
||||||
|
const filterContainer = document.getElementById('guidesFilter');
|
||||||
|
filterContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (!this.guides || !Array.isArray(this.guides)) {
|
||||||
|
filterContainer.innerHTML = '<div class="error">Нет доступных гидов</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.guides.forEach(guide => {
|
||||||
|
const checkbox = document.createElement('label');
|
||||||
|
checkbox.className = 'guide-checkbox';
|
||||||
|
if (this.selectedGuides.has(guide.id)) {
|
||||||
|
checkbox.classList.add('checked');
|
||||||
|
}
|
||||||
|
|
||||||
|
checkbox.innerHTML = `
|
||||||
|
<input type="checkbox"
|
||||||
|
${this.selectedGuides.has(guide.id) ? 'checked' : ''}
|
||||||
|
data-guide-id="${guide.id}">
|
||||||
|
<span>${guide.name}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
checkbox.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
this.toggleGuide(guide.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
filterContainer.appendChild(checkbox);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleGuide(guideId) {
|
||||||
|
if (this.selectedGuides.has(guideId)) {
|
||||||
|
this.selectedGuides.delete(guideId);
|
||||||
|
} else {
|
||||||
|
this.selectedGuides.add(guideId);
|
||||||
|
}
|
||||||
|
this.renderGuidesFilter();
|
||||||
|
this.renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCalendar() {
|
||||||
|
const grid = document.getElementById('calendarGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
// Заголовки дней недели
|
||||||
|
const dayHeaders = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
dayHeaders.forEach(day => {
|
||||||
|
const headerDiv = document.createElement('div');
|
||||||
|
headerDiv.className = 'day-header';
|
||||||
|
headerDiv.textContent = day;
|
||||||
|
grid.appendChild(headerDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
|
||||||
|
// Первый день месяца
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
|
||||||
|
// Первый понедельник на календаре
|
||||||
|
const startDate = new Date(firstDay);
|
||||||
|
const dayOfWeek = firstDay.getDay();
|
||||||
|
const mondayOffset = dayOfWeek === 0 ? -6 : -(dayOfWeek - 1);
|
||||||
|
startDate.setDate(firstDay.getDate() + mondayOffset);
|
||||||
|
|
||||||
|
// Генерируем 6 недель
|
||||||
|
for (let week = 0; week < 6; week++) {
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
const currentDay = new Date(startDate);
|
||||||
|
currentDay.setDate(startDate.getDate() + week * 7 + day);
|
||||||
|
|
||||||
|
const dayDiv = document.createElement('div');
|
||||||
|
dayDiv.className = 'calendar-day';
|
||||||
|
|
||||||
|
if (currentDay.getMonth() !== month) {
|
||||||
|
dayDiv.classList.add('other-month');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isToday(currentDay)) {
|
||||||
|
dayDiv.classList.add('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
dayDiv.innerHTML = this.renderDay(currentDay);
|
||||||
|
grid.appendChild(dayDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDay(date) {
|
||||||
|
const dayNumber = date.getDate();
|
||||||
|
const dateStr = this.formatDate(date);
|
||||||
|
|
||||||
|
let guideStatusHtml = '';
|
||||||
|
|
||||||
|
// Получаем статусы выбранных гидов для этого дня
|
||||||
|
this.guides.forEach(guide => {
|
||||||
|
if (!this.selectedGuides.has(guide.id)) return;
|
||||||
|
|
||||||
|
const status = this.getGuideStatus(guide.id, dateStr);
|
||||||
|
const statusClass = status === 'holiday' ? 'holiday' :
|
||||||
|
status === 'busy' ? 'busy' : 'working';
|
||||||
|
|
||||||
|
guideStatusHtml += `<div class="guide-badge ${statusClass}">${guide.name.split(' ')[0]}</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="day-number">${dayNumber}</div>
|
||||||
|
<div class="guide-status">${guideStatusHtml}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGuideStatus(guideId, dateStr) {
|
||||||
|
// Проверяем выходные дни
|
||||||
|
const holiday = this.holidays.find(h =>
|
||||||
|
h.guide_id === guideId && h.holiday_date === dateStr
|
||||||
|
);
|
||||||
|
if (holiday) return 'holiday';
|
||||||
|
|
||||||
|
// Проверяем бронирования
|
||||||
|
const booking = this.bookings.find(b =>
|
||||||
|
b.guide_id === guideId &&
|
||||||
|
this.formatDate(new Date(b.preferred_date)) === dateStr
|
||||||
|
);
|
||||||
|
if (booking) return 'busy';
|
||||||
|
|
||||||
|
return 'working';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
isToday(date) {
|
||||||
|
const today = new Date();
|
||||||
|
return date.toDateString() === today.toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMonthDisplay() {
|
||||||
|
const monthDisplay = document.getElementById('currentDate');
|
||||||
|
const monthNames = [
|
||||||
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||||
|
];
|
||||||
|
monthDisplay.textContent = `${monthNames[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeMonth(delta) {
|
||||||
|
this.currentDate.setMonth(this.currentDate.getMonth() + delta);
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateMonthDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация календаря
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
new GuideCalendar();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
55
public/admin-calendar-full.html
Normal file
55
public/admin-calendar-full.html
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Календарь управления гидами</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid p-0">
|
||||||
|
<!-- Заголовок -->
|
||||||
|
<div class="bg-primary text-white p-3 mb-0">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="fas fa-calendar-alt me-2"></i>
|
||||||
|
Управление календарем гидов
|
||||||
|
</h4>
|
||||||
|
<small>Управляйте расписанием и доступностью гидов</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Контент календаря -->
|
||||||
|
<div class="p-3">
|
||||||
|
<div id="admin-calendar-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bootstrap JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Компоненты календаря -->
|
||||||
|
<script src="/components/guide-calendar-widget.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Инициализация календаря для администратора
|
||||||
|
const adminCalendar = new GuideCalendarWidget({
|
||||||
|
container: document.getElementById('admin-calendar-container'),
|
||||||
|
mode: 'admin',
|
||||||
|
showControls: true,
|
||||||
|
showGuideInfo: true,
|
||||||
|
allowEdit: true,
|
||||||
|
onDateSelect: function(date, guide) {
|
||||||
|
console.log('Выбрана дата:', date, 'Гид:', guide);
|
||||||
|
},
|
||||||
|
onHolidayAdd: function(guide, date) {
|
||||||
|
console.log('Добавлен выходной:', guide, date);
|
||||||
|
},
|
||||||
|
onBookingView: function(booking) {
|
||||||
|
console.log('Просмотр бронирования:', booking);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
223
public/components/admin-calendar-resource.jsx
Normal file
223
public/components/admin-calendar-resource.jsx
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import React, { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const AdminCalendarResource = () => {
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date())
|
||||||
|
const [guides, setGuides] = useState([])
|
||||||
|
const [selectedGuide, setSelectedGuide] = useState(null)
|
||||||
|
const [workingDays, setWorkingDays] = useState([])
|
||||||
|
const [holidays, setHolidays] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData()
|
||||||
|
}, [currentDate])
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const [guidesRes, holidaysRes] = await Promise.all([
|
||||||
|
fetch('/api/guides'),
|
||||||
|
fetch('/api/holidays')
|
||||||
|
])
|
||||||
|
|
||||||
|
const guidesData = await guidesRes.json()
|
||||||
|
const holidaysData = await holidaysRes.json()
|
||||||
|
|
||||||
|
setGuides(guidesData.data || guidesData)
|
||||||
|
setHolidays(holidaysData)
|
||||||
|
|
||||||
|
if (selectedGuide) {
|
||||||
|
const workingRes = await fetch(`/api/guide-working-days?guide_id=${selectedGuide}&month=${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`)
|
||||||
|
const workingData = await workingRes.json()
|
||||||
|
setWorkingDays(workingData)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading data:', error)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDaysInMonth = (date) => {
|
||||||
|
const year = date.getFullYear()
|
||||||
|
const month = date.getMonth()
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
const firstDayOfWeek = new Date(year, month, 1).getDay()
|
||||||
|
|
||||||
|
const days = []
|
||||||
|
|
||||||
|
// Добавляем пустые дни в начале
|
||||||
|
for (let i = 0; i < (firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1); i++) {
|
||||||
|
days.push(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем дни месяца
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
days.push(day)
|
||||||
|
}
|
||||||
|
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
const isWorkingDay = (day) => {
|
||||||
|
if (!day || !selectedGuide) return false
|
||||||
|
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
return workingDays.some(wd => wd.work_date === dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHoliday = (day) => {
|
||||||
|
if (!day) return false
|
||||||
|
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
return holidays.some(h => h.date === dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleWorkingDay = async (day) => {
|
||||||
|
if (!selectedGuide || !day) return
|
||||||
|
|
||||||
|
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
|
||||||
|
const isWorking = isWorkingDay(day)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isWorking) {
|
||||||
|
await fetch('/api/guide-working-days', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ guide_id: selectedGuide, work_date: dateStr })
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
await fetch('/api/guide-working-days', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ guide_id: selectedGuide, work_date: dateStr })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
loadData()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling working day:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeMonth = (delta) => {
|
||||||
|
const newDate = new Date(currentDate)
|
||||||
|
newDate.setMonth(newDate.getMonth() + delta)
|
||||||
|
setCurrentDate(newDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||||
|
]
|
||||||
|
|
||||||
|
const weekDays = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС']
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||||
|
<div>Загрузка календаря...</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<h2>Календарь рабочих дней гидов</h2>
|
||||||
|
|
||||||
|
<div style={{ marginBottom: '20px' }}>
|
||||||
|
<label style={{ display: 'block', marginBottom: '5px' }}>Выберите гида:</label>
|
||||||
|
<select
|
||||||
|
value={selectedGuide || ''}
|
||||||
|
onChange={(e) => setSelectedGuide(e.target.value)}
|
||||||
|
style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ddd', minWidth: '200px' }}
|
||||||
|
>
|
||||||
|
<option value="">-- Выберите гида --</option>
|
||||||
|
{guides.map(guide => (
|
||||||
|
<option key={guide.id} value={guide.id}>{guide.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '20px', marginBottom: '20px' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => changeMonth(-1)}
|
||||||
|
style={{ padding: '8px 16px', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
← Предыдущий
|
||||||
|
</button>
|
||||||
|
<h3 style={{ margin: 0 }}>
|
||||||
|
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => changeMonth(1)}
|
||||||
|
style={{ padding: '8px 16px', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
Следующий →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedGuide && (
|
||||||
|
<div>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '1px', marginBottom: '20px' }}>
|
||||||
|
{weekDays.map(day => (
|
||||||
|
<div key={day} style={{
|
||||||
|
padding: '10px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
border: '1px solid #ddd'
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '1px' }}>
|
||||||
|
{getDaysInMonth(currentDate).map((day, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
onClick={() => toggleWorkingDay(day)}
|
||||||
|
style={{
|
||||||
|
padding: '15px',
|
||||||
|
textAlign: 'center',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
minHeight: '50px',
|
||||||
|
cursor: day ? 'pointer' : 'default',
|
||||||
|
background: day ?
|
||||||
|
(isHoliday(day) ? '#ffcccb' :
|
||||||
|
isWorkingDay(day) ? '#c8e6c9' : 'white') : '#f9f9f9',
|
||||||
|
color: day ? (isHoliday(day) ? '#d32f2f' : '#333') : '#ccc',
|
||||||
|
fontWeight: day ? 'normal' : '300'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day || ''}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ marginTop: '20px', display: 'flex', gap: '20px', fontSize: '14px' }}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||||
|
<div style={{ width: '20px', height: '20px', background: '#c8e6c9', border: '1px solid #ddd' }}></div>
|
||||||
|
<span>Рабочий день</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||||
|
<div style={{ width: '20px', height: '20px', background: '#ffcccb', border: '1px solid #ddd' }}></div>
|
||||||
|
<span>Выходной/Праздник</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
|
||||||
|
<div style={{ width: '20px', height: '20px', background: 'white', border: '1px solid #ddd' }}></div>
|
||||||
|
<span>Не назначено</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!selectedGuide && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>
|
||||||
|
Выберите гида для просмотра календаря
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AdminCalendarResource
|
||||||
306
public/components/availability-checker.js
Normal file
306
public/components/availability-checker.js
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
/**
|
||||||
|
* AvailabilityChecker - Компонент для проверки доступности гидов
|
||||||
|
* Используется в формах бронирования для быстрой проверки
|
||||||
|
*/
|
||||||
|
|
||||||
|
class AvailabilityChecker {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.container = options.container || document.body;
|
||||||
|
this.mode = options.mode || 'simple'; // 'simple', 'detailed', 'inline'
|
||||||
|
this.onAvailabilityCheck = options.onAvailabilityCheck || null;
|
||||||
|
this.showSuggestions = options.showSuggestions !== false;
|
||||||
|
this.maxSuggestions = options.maxSuggestions || 3;
|
||||||
|
|
||||||
|
this.guides = [];
|
||||||
|
this.schedules = [];
|
||||||
|
this.holidays = [];
|
||||||
|
this.bookings = [];
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.render();
|
||||||
|
await this.loadData();
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const modeClass = `availability-checker-${this.mode}`;
|
||||||
|
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="availability-checker ${modeClass}">
|
||||||
|
${this.mode === 'detailed' ? `
|
||||||
|
<div class="checker-header">
|
||||||
|
<h4>Проверка доступности</h4>
|
||||||
|
<p>Укажите дату и тип тура для проверки доступности гидов</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="checker-form" id="checkerForm-${this.getId()}">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="checkDate-${this.getId()}">Дата тура:</label>
|
||||||
|
<input type="date"
|
||||||
|
id="checkDate-${this.getId()}"
|
||||||
|
min="${new Date().toISOString().split('T')[0]}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.mode === 'detailed' ? `
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="tourType-${this.getId()}">Тип тура:</label>
|
||||||
|
<select id="tourType-${this.getId()}">
|
||||||
|
<option value="">Любой</option>
|
||||||
|
<option value="city">Городской тур</option>
|
||||||
|
<option value="mountain">Горный поход</option>
|
||||||
|
<option value="fishing">Рыбалка</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="groupSize-${this.getId()}">Размер группы:</label>
|
||||||
|
<input type="number"
|
||||||
|
id="groupSize-${this.getId()}"
|
||||||
|
min="1"
|
||||||
|
max="20"
|
||||||
|
value="1">
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<button type="button"
|
||||||
|
id="checkButton-${this.getId()}"
|
||||||
|
class="check-button">
|
||||||
|
🔍 Проверить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="checker-results" id="checkerResults-${this.getId()}" style="display: none;">
|
||||||
|
<div class="results-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.showSuggestions ? `
|
||||||
|
<div class="checker-suggestions" id="checkerSuggestions-${this.getId()}" style="display: none;">
|
||||||
|
<h5>Альтернативные варианты:</h5>
|
||||||
|
<div class="suggestions-list"></div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.injectStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
if (!this._id) {
|
||||||
|
this._id = 'availability-checker-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
injectStyles() {
|
||||||
|
if (document.getElementById('availability-checker-styles')) return;
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
<style id="availability-checker-styles">
|
||||||
|
.availability-checker {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checker-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checker-form {
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
align-items: end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-button {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-summary {
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-summary.available {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results-summary.unavailable {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.available-guide {
|
||||||
|
padding: 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #28a745;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.insertAdjacentHTML('beforeend', styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
try {
|
||||||
|
const [guidesRes, holidaysRes, bookingsRes] = await Promise.all([
|
||||||
|
fetch('/api/guides'),
|
||||||
|
fetch('/api/holidays'),
|
||||||
|
fetch('/api/bookings')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const guidesData = await guidesRes.json();
|
||||||
|
const holidaysData = await holidaysRes.json();
|
||||||
|
const bookingsData = await bookingsRes.json();
|
||||||
|
|
||||||
|
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || []);
|
||||||
|
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || []);
|
||||||
|
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || []);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки данных:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
const checkButton = this.container.querySelector(`#checkButton-${this.getId()}`);
|
||||||
|
checkButton.addEventListener('click', () => this.checkAvailability());
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAvailability() {
|
||||||
|
const dateInput = this.container.querySelector(`#checkDate-${this.getId()}`);
|
||||||
|
const date = dateInput.value;
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
alert('Выберите дату');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableGuides = this.getAvailableGuides(date);
|
||||||
|
const resultsContainer = this.container.querySelector(`#checkerResults-${this.getId()}`);
|
||||||
|
const resultsContent = resultsContainer.querySelector('.results-content');
|
||||||
|
|
||||||
|
if (availableGuides.length === 0) {
|
||||||
|
resultsContent.innerHTML = `
|
||||||
|
<div class="results-summary unavailable">
|
||||||
|
<span>❌</span>
|
||||||
|
<div>Нет доступных гидов на выбранную дату</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
resultsContent.innerHTML = `
|
||||||
|
<div class="results-summary available">
|
||||||
|
<span>✅</span>
|
||||||
|
<div>Доступно ${availableGuides.length} гидов</div>
|
||||||
|
</div>
|
||||||
|
${availableGuides.map(guide => `
|
||||||
|
<div class="available-guide">
|
||||||
|
<div class="guide-info">
|
||||||
|
<div class="guide-name">${guide.name}</div>
|
||||||
|
<div>${guide.specialization || 'Универсальный'}</div>
|
||||||
|
</div>
|
||||||
|
<div>${guide.hourly_rate ? guide.hourly_rate + '₩/час' : 'По договоренности'}</div>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsContainer.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableGuides(date) {
|
||||||
|
return this.guides.filter(guide => {
|
||||||
|
const holiday = this.holidays.find(h => h.guide_id === guide.id && h.holiday_date === date);
|
||||||
|
if (holiday) return false;
|
||||||
|
|
||||||
|
const booking = this.bookings.find(b =>
|
||||||
|
b.guide_id === guide.id &&
|
||||||
|
new Date(b.preferred_date).toISOString().split('T')[0] === date
|
||||||
|
);
|
||||||
|
if (booking) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
if (!this._id) {
|
||||||
|
this._id = 'checker-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.AvailabilityChecker = AvailabilityChecker;
|
||||||
|
}
|
||||||
373
public/components/guide-calendar-view.jsx
Normal file
373
public/components/guide-calendar-view.jsx
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const GuideCalendarView = () => {
|
||||||
|
const [currentDate, setCurrentDate] = useState(new Date());
|
||||||
|
const [workingDays, setWorkingDays] = useState([]);
|
||||||
|
const [guides, setGuides] = useState([]);
|
||||||
|
const [selectedGuide, setSelectedGuide] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [stats, setStats] = useState({ totalDays: 0, totalGuides: 0 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadGuides();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadWorkingDays();
|
||||||
|
}, [currentDate, selectedGuide]);
|
||||||
|
|
||||||
|
const loadGuides = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/guides');
|
||||||
|
const data = await response.json();
|
||||||
|
setGuides(data.success ? data.data : data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading guides:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadWorkingDays = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const month = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
|
||||||
|
const url = selectedGuide
|
||||||
|
? `/api/guide-working-days?month=${month}&guide_id=${selectedGuide}`
|
||||||
|
: `/api/guide-working-days?month=${month}`;
|
||||||
|
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
setWorkingDays(data);
|
||||||
|
|
||||||
|
// Подсчет статистики
|
||||||
|
const uniqueGuides = new Set(data.map(d => d.guide_id));
|
||||||
|
setStats({
|
||||||
|
totalDays: data.length,
|
||||||
|
totalGuides: uniqueGuides.size
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading working days:', error);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDaysInMonth = (date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const firstDayOfWeek = new Date(year, month, 1).getDay();
|
||||||
|
|
||||||
|
const days = [];
|
||||||
|
|
||||||
|
// Добавляем пустые дни в начале
|
||||||
|
for (let i = 0; i < (firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1); i++) {
|
||||||
|
days.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем дни месяца
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
days.push(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
return days;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWorkingDaysForDate = (day) => {
|
||||||
|
if (!day) return [];
|
||||||
|
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
return workingDays.filter(wd => wd.work_date === dateStr);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getGuideById = (id) => {
|
||||||
|
return guides.find(g => g.id === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeMonth = (delta) => {
|
||||||
|
const newDate = new Date(currentDate);
|
||||||
|
newDate.setMonth(newDate.getMonth() + delta);
|
||||||
|
setCurrentDate(newDate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||||
|
];
|
||||||
|
|
||||||
|
const weekDays = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС'];
|
||||||
|
|
||||||
|
if (loading && workingDays.length === 0) {
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', textAlign: 'center' }}>
|
||||||
|
Загрузка календаря...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
|
||||||
|
{/* Заголовок и статистика */}
|
||||||
|
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<h2>📅 Календарь рабочих дней гидов</h2>
|
||||||
|
<div style={{ display: 'flex', gap: '20px', fontSize: '14px' }}>
|
||||||
|
<div style={{ padding: '8px 12px', background: '#e3f2fd', borderRadius: '6px' }}>
|
||||||
|
<strong>{stats.totalDays}</strong> рабочих дней
|
||||||
|
</div>
|
||||||
|
<div style={{ padding: '8px 12px', background: '#f3e5f5', borderRadius: '6px' }}>
|
||||||
|
<strong>{stats.totalGuides}</strong> активных гидов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Фильтр по гиду */}
|
||||||
|
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'center', gap: '15px' }}>
|
||||||
|
<label style={{ fontWeight: 'bold' }}>Фильтр по гиду:</label>
|
||||||
|
<select
|
||||||
|
value={selectedGuide}
|
||||||
|
onChange={(e) => setSelectedGuide(e.target.value)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
minWidth: '200px',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Все гиды</option>
|
||||||
|
{guides.map(guide => (
|
||||||
|
<option key={guide.id} value={guide.id}>
|
||||||
|
{guide.name} ({guide.specialization})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{selectedGuide && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedGuide('')}
|
||||||
|
style={{
|
||||||
|
padding: '6px 12px',
|
||||||
|
background: '#ff5722',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '12px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Очистить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Навигация по месяцам */}
|
||||||
|
<div style={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: '20px',
|
||||||
|
padding: '15px',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => changeMonth(-1)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
← Предыдущий
|
||||||
|
</button>
|
||||||
|
<h3 style={{ margin: 0, color: '#333' }}>
|
||||||
|
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => changeMonth(1)}
|
||||||
|
style={{
|
||||||
|
padding: '10px 20px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '6px',
|
||||||
|
background: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 'bold'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Следующий →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Календарная сетка */}
|
||||||
|
<div style={{
|
||||||
|
background: 'white',
|
||||||
|
borderRadius: '8px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
|
||||||
|
}}>
|
||||||
|
{/* Заголовки дней недели */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
||||||
|
{weekDays.map(day => (
|
||||||
|
<div key={day} style={{
|
||||||
|
padding: '15px',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
background: '#f5f5f5',
|
||||||
|
borderBottom: '1px solid #ddd',
|
||||||
|
color: '#333'
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Дни месяца */}
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
|
||||||
|
{getDaysInMonth(currentDate).map((day, index) => {
|
||||||
|
const dayWorkingData = getWorkingDaysForDate(day);
|
||||||
|
const hasData = dayWorkingData.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
style={{
|
||||||
|
padding: '10px',
|
||||||
|
minHeight: '120px',
|
||||||
|
border: '1px solid #e0e0e0',
|
||||||
|
background: day ? (hasData ? '#e8f5e8' : 'white') : '#f9f9f9',
|
||||||
|
color: day ? '#333' : '#ccc',
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{day && (
|
||||||
|
<>
|
||||||
|
<div style={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
marginBottom: '8px',
|
||||||
|
color: hasData ? '#2e7d32' : '#666'
|
||||||
|
}}>
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{dayWorkingData.map((workDay, idx) => {
|
||||||
|
const guide = getGuideById(workDay.guide_id);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
style={{
|
||||||
|
fontSize: '11px',
|
||||||
|
padding: '4px 6px',
|
||||||
|
margin: '2px 0',
|
||||||
|
background: guide?.specialization === 'city' ? '#bbdefb' :
|
||||||
|
guide?.specialization === 'mountain' ? '#c8e6c9' :
|
||||||
|
guide?.specialization === 'fishing' ? '#ffcdd2' : '#f0f0f0',
|
||||||
|
borderRadius: '4px',
|
||||||
|
color: '#333',
|
||||||
|
lineHeight: '1.2'
|
||||||
|
}}
|
||||||
|
title={workDay.notes}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 'bold' }}>
|
||||||
|
{guide?.name || `Гид #${workDay.guide_id}`}
|
||||||
|
</div>
|
||||||
|
{workDay.notes && (
|
||||||
|
<div style={{ opacity: 0.8, marginTop: '2px' }}>
|
||||||
|
{workDay.notes.length > 20 ? workDay.notes.substring(0, 20) + '...' : workDay.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Легенда */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '20px',
|
||||||
|
fontSize: '14px',
|
||||||
|
flexWrap: 'wrap'
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div style={{ width: '20px', height: '20px', background: '#bbdefb', borderRadius: '4px' }}></div>
|
||||||
|
<span>Городские туры</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div style={{ width: '20px', height: '20px', background: '#c8e6c9', borderRadius: '4px' }}></div>
|
||||||
|
<span>Горные туры</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div style={{ width: '20px', height: '20px', background: '#ffcdd2', borderRadius: '4px' }}></div>
|
||||||
|
<span>Морская рыбалка</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<div style={{ width: '20px', height: '20px', background: '#e8f5e8', borderRadius: '4px' }}></div>
|
||||||
|
<span>Рабочий день</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Быстрые действия */}
|
||||||
|
<div style={{
|
||||||
|
marginTop: '20px',
|
||||||
|
padding: '15px',
|
||||||
|
background: '#f8f9fa',
|
||||||
|
borderRadius: '8px'
|
||||||
|
}}>
|
||||||
|
<h4 style={{ margin: '0 0 10px 0' }}>Быстрые действия:</h4>
|
||||||
|
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open('/admin/calendar-view', '_blank')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#2196f3',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
📅 Полный календарь
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => window.open('/admin/schedule-manager', '_blank')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#4caf50',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
⚡ Планировщик смен
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => loadWorkingDays()}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
background: '#ff9800',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '6px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
🔄 Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GuideCalendarView;
|
||||||
633
public/components/guide-calendar-widget.js
Normal file
633
public/components/guide-calendar-widget.js
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
/**
|
||||||
|
* GuideCalendarWidget - Переиспользуемый компонент календаря гидов
|
||||||
|
* Может использоваться на фронтенде для бронирования и в админке
|
||||||
|
*/
|
||||||
|
|
||||||
|
class GuideCalendarWidget {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.container = options.container || document.body;
|
||||||
|
this.mode = options.mode || 'booking'; // 'booking', 'admin', 'readonly'
|
||||||
|
this.onDateSelect = options.onDateSelect || null;
|
||||||
|
this.onGuideSelect = options.onGuideSelect || null;
|
||||||
|
this.showGuideFilter = options.showGuideFilter !== false;
|
||||||
|
this.showLegend = options.showLegend !== false;
|
||||||
|
this.compact = options.compact || false;
|
||||||
|
this.selectedDate = options.selectedDate || null;
|
||||||
|
this.selectedGuideId = options.selectedGuideId || null;
|
||||||
|
|
||||||
|
this.currentDate = new Date();
|
||||||
|
this.guides = [];
|
||||||
|
this.schedules = [];
|
||||||
|
this.holidays = [];
|
||||||
|
this.bookings = [];
|
||||||
|
this.selectedGuides = new Set();
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.render();
|
||||||
|
await this.loadData();
|
||||||
|
this.renderGuidesFilter();
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateMonthDisplay();
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const compactClass = this.compact ? 'calendar-compact' : '';
|
||||||
|
const modeClass = `calendar-mode-${this.mode}`;
|
||||||
|
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="guide-calendar-widget ${compactClass} ${modeClass}">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<div class="calendar-navigation">
|
||||||
|
<button class="nav-button" data-action="prev-month">‹</button>
|
||||||
|
<span class="current-date" id="currentDate-${this.getId()}"></span>
|
||||||
|
<button class="nav-button" data-action="next-month">›</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.showGuideFilter ? `
|
||||||
|
<div class="guides-filter">
|
||||||
|
<span class="filter-label">Гиды:</span>
|
||||||
|
<div class="guides-filter-container" id="guidesFilter-${this.getId()}"></div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-grid" id="calendarGrid-${this.getId()}"></div>
|
||||||
|
|
||||||
|
${this.showLegend ? `
|
||||||
|
<div class="calendar-legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color legend-working"></div>
|
||||||
|
<span>Доступен</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color legend-holiday"></div>
|
||||||
|
<span>Выходной</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color legend-busy"></div>
|
||||||
|
<span>Занят</span>
|
||||||
|
</div>
|
||||||
|
${this.mode === 'booking' ? `
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color legend-selected"></div>
|
||||||
|
<span>Выбранная дата</span>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.injectStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
if (!this._id) {
|
||||||
|
this._id = 'calendar-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
injectStyles() {
|
||||||
|
if (document.getElementById('guide-calendar-styles')) return;
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
<style id="guide-calendar-styles">
|
||||||
|
.guide-calendar-widget {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-navigation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-date {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #343a40;
|
||||||
|
min-width: 150px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guides-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guides-filter-container {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 15px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background: white;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox.checked {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox input[type="checkbox"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
background: white;
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 6px;
|
||||||
|
position: relative;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:hover {
|
||||||
|
background: #f0f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.other-month {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.today {
|
||||||
|
background: #fff3cd;
|
||||||
|
border-color: #ffeaa7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.selected {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: inset 0 0 0 2px #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
background: #343a40;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 6px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-badge {
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-badge.working {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-badge.holiday {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-badge.busy {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-legend {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-working { background: #d4edda; }
|
||||||
|
.legend-holiday { background: #f8d7da; }
|
||||||
|
.legend-busy { background: #fff3cd; }
|
||||||
|
.legend-selected { background: #e3f2fd; border: 1px solid #007bff; }
|
||||||
|
|
||||||
|
/* Компактный режим */
|
||||||
|
.calendar-compact .calendar-day {
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-compact .day-number {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-compact .guide-badge {
|
||||||
|
font-size: 8px;
|
||||||
|
padding: 1px 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Режим бронирования */
|
||||||
|
.calendar-mode-booking .calendar-day:hover {
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Режим только для чтения */
|
||||||
|
.calendar-mode-readonly .calendar-day {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-mode-readonly .calendar-day:hover {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.insertAdjacentHTML('beforeend', styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
this.container.addEventListener('click', (e) => {
|
||||||
|
if (e.target.matches('[data-action="prev-month"]')) {
|
||||||
|
this.changeMonth(-1);
|
||||||
|
} else if (e.target.matches('[data-action="next-month"]')) {
|
||||||
|
this.changeMonth(1);
|
||||||
|
} else if (e.target.closest('.guide-checkbox')) {
|
||||||
|
const checkbox = e.target.closest('.guide-checkbox');
|
||||||
|
const guideId = parseInt(checkbox.dataset.guideId);
|
||||||
|
this.toggleGuide(guideId);
|
||||||
|
} else if (e.target.closest('.calendar-day')) {
|
||||||
|
const dayEl = e.target.closest('.calendar-day');
|
||||||
|
const dateStr = dayEl.dataset.date;
|
||||||
|
if (dateStr && this.mode === 'booking') {
|
||||||
|
this.selectDate(dateStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
try {
|
||||||
|
// Загружаем гидов
|
||||||
|
const guidesResponse = await fetch('/api/guides');
|
||||||
|
const guidesData = await guidesResponse.json();
|
||||||
|
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || []);
|
||||||
|
|
||||||
|
// Загружаем остальные данные параллельно
|
||||||
|
const [schedulesRes, holidaysRes, bookingsRes] = await Promise.all([
|
||||||
|
fetch('/api/guide-schedules'),
|
||||||
|
fetch('/api/holidays'),
|
||||||
|
fetch('/api/bookings')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const schedulesData = await schedulesRes.json();
|
||||||
|
const holidaysData = await holidaysRes.json();
|
||||||
|
const bookingsData = await bookingsRes.json();
|
||||||
|
|
||||||
|
this.schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData.data || []);
|
||||||
|
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || []);
|
||||||
|
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || []);
|
||||||
|
|
||||||
|
// Инициализируем выбранных гидов
|
||||||
|
if (this.guides && this.guides.length > 0) {
|
||||||
|
if (this.selectedGuideId) {
|
||||||
|
this.selectedGuides.add(this.selectedGuideId);
|
||||||
|
} else {
|
||||||
|
this.guides.forEach(guide => this.selectedGuides.add(guide.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки данных календаря:', error);
|
||||||
|
this.showError('Ошибка загрузки данных календаря');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const gridEl = this.container.querySelector(`#calendarGrid-${this.getId()}`);
|
||||||
|
if (gridEl) {
|
||||||
|
gridEl.innerHTML = `<div class="error">${message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGuidesFilter() {
|
||||||
|
if (!this.showGuideFilter) return;
|
||||||
|
|
||||||
|
const filterContainer = this.container.querySelector(`#guidesFilter-${this.getId()}`);
|
||||||
|
if (!filterContainer) return;
|
||||||
|
|
||||||
|
filterContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (!this.guides || !Array.isArray(this.guides) || this.guides.length === 0) {
|
||||||
|
filterContainer.innerHTML = '<div class="error">Нет доступных гидов</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.guides.forEach(guide => {
|
||||||
|
const checkbox = document.createElement('label');
|
||||||
|
checkbox.className = 'guide-checkbox';
|
||||||
|
checkbox.dataset.guideId = guide.id;
|
||||||
|
|
||||||
|
if (this.selectedGuides.has(guide.id)) {
|
||||||
|
checkbox.classList.add('checked');
|
||||||
|
}
|
||||||
|
|
||||||
|
checkbox.innerHTML = `
|
||||||
|
<input type="checkbox" ${this.selectedGuides.has(guide.id) ? 'checked' : ''}>
|
||||||
|
<span>${guide.name.split(' ')[0]}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
filterContainer.appendChild(checkbox);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleGuide(guideId) {
|
||||||
|
if (this.selectedGuides.has(guideId)) {
|
||||||
|
this.selectedGuides.delete(guideId);
|
||||||
|
} else {
|
||||||
|
this.selectedGuides.add(guideId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderGuidesFilter();
|
||||||
|
this.renderCalendar();
|
||||||
|
|
||||||
|
if (this.onGuideSelect) {
|
||||||
|
this.onGuideSelect(Array.from(this.selectedGuides));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectDate(dateStr) {
|
||||||
|
this.selectedDate = dateStr;
|
||||||
|
this.renderCalendar();
|
||||||
|
|
||||||
|
if (this.onDateSelect) {
|
||||||
|
this.onDateSelect(dateStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCalendar() {
|
||||||
|
const grid = this.container.querySelector(`#calendarGrid-${this.getId()}`);
|
||||||
|
if (!grid) return;
|
||||||
|
|
||||||
|
grid.innerHTML = '';
|
||||||
|
|
||||||
|
// Заголовки дней недели
|
||||||
|
const dayHeaders = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
dayHeaders.forEach(day => {
|
||||||
|
const headerDiv = document.createElement('div');
|
||||||
|
headerDiv.className = 'day-header';
|
||||||
|
headerDiv.textContent = day;
|
||||||
|
grid.appendChild(headerDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
|
||||||
|
// Первый день месяца
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
|
||||||
|
// Первый понедельник на календаре
|
||||||
|
const startDate = new Date(firstDay);
|
||||||
|
const dayOfWeek = firstDay.getDay();
|
||||||
|
const mondayOffset = dayOfWeek === 0 ? -6 : -(dayOfWeek - 1);
|
||||||
|
startDate.setDate(firstDay.getDate() + mondayOffset);
|
||||||
|
|
||||||
|
// Генерируем 6 недель
|
||||||
|
for (let week = 0; week < 6; week++) {
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
const currentDay = new Date(startDate);
|
||||||
|
currentDay.setDate(startDate.getDate() + week * 7 + day);
|
||||||
|
|
||||||
|
const dayDiv = document.createElement('div');
|
||||||
|
dayDiv.className = 'calendar-day';
|
||||||
|
dayDiv.dataset.date = this.formatDate(currentDay);
|
||||||
|
|
||||||
|
if (currentDay.getMonth() !== month) {
|
||||||
|
dayDiv.classList.add('other-month');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isToday(currentDay)) {
|
||||||
|
dayDiv.classList.add('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.selectedDate === this.formatDate(currentDay)) {
|
||||||
|
dayDiv.classList.add('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
dayDiv.innerHTML = this.renderDay(currentDay);
|
||||||
|
grid.appendChild(dayDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderDay(date) {
|
||||||
|
const dayNumber = date.getDate();
|
||||||
|
const dateStr = this.formatDate(date);
|
||||||
|
|
||||||
|
let guideStatusHtml = '';
|
||||||
|
|
||||||
|
// Получаем статусы выбранных гидов для этого дня
|
||||||
|
this.guides.forEach(guide => {
|
||||||
|
if (!this.selectedGuides.has(guide.id)) return;
|
||||||
|
|
||||||
|
const status = this.getGuideStatus(guide.id, dateStr);
|
||||||
|
const statusClass = status === 'holiday' ? 'holiday' :
|
||||||
|
status === 'busy' ? 'busy' : 'working';
|
||||||
|
|
||||||
|
guideStatusHtml += `<div class="guide-badge ${statusClass}" title="${guide.name} - ${this.getStatusText(status)}">${guide.name.split(' ')[0]}</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="day-number">${dayNumber}</div>
|
||||||
|
<div class="guide-status">${guideStatusHtml}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'working': 'Доступен',
|
||||||
|
'holiday': 'Выходной',
|
||||||
|
'busy': 'Занят'
|
||||||
|
};
|
||||||
|
return statusMap[status] || 'Неизвестно';
|
||||||
|
}
|
||||||
|
|
||||||
|
getGuideStatus(guideId, dateStr) {
|
||||||
|
// Проверяем выходные дни
|
||||||
|
const holiday = this.holidays.find(h =>
|
||||||
|
h.guide_id === guideId && h.holiday_date === dateStr
|
||||||
|
);
|
||||||
|
if (holiday) return 'holiday';
|
||||||
|
|
||||||
|
// Проверяем бронирования
|
||||||
|
const booking = this.bookings.find(b =>
|
||||||
|
b.guide_id === guideId &&
|
||||||
|
this.formatDate(new Date(b.preferred_date)) === dateStr
|
||||||
|
);
|
||||||
|
if (booking) return 'busy';
|
||||||
|
|
||||||
|
return 'working';
|
||||||
|
}
|
||||||
|
|
||||||
|
formatDate(date) {
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
isToday(date) {
|
||||||
|
const today = new Date();
|
||||||
|
return date.toDateString() === today.toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMonthDisplay() {
|
||||||
|
const monthDisplay = this.container.querySelector(`#currentDate-${this.getId()}`);
|
||||||
|
if (!monthDisplay) return;
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||||
|
];
|
||||||
|
monthDisplay.textContent = `${monthNames[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeMonth(delta) {
|
||||||
|
this.currentDate.setMonth(this.currentDate.getMonth() + delta);
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateMonthDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Публичные методы для внешнего управления
|
||||||
|
setSelectedDate(dateStr) {
|
||||||
|
this.selectedDate = dateStr;
|
||||||
|
this.renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedGuide(guideId) {
|
||||||
|
this.selectedGuides.clear();
|
||||||
|
this.selectedGuides.add(guideId);
|
||||||
|
this.renderGuidesFilter();
|
||||||
|
this.renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableGuides(dateStr) {
|
||||||
|
return this.guides.filter(guide =>
|
||||||
|
this.getGuideStatus(guide.id, dateStr) === 'working'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadData().then(() => {
|
||||||
|
this.renderGuidesFilter();
|
||||||
|
this.renderCalendar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспортируем для использования в других файлах
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = GuideCalendarWidget;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Глобальная доступность в браузере
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.GuideCalendarWidget = GuideCalendarWidget;
|
||||||
|
}
|
||||||
865
public/components/guide-schedule-manager.js
Normal file
865
public/components/guide-schedule-manager.js
Normal file
@@ -0,0 +1,865 @@
|
|||||||
|
/**
|
||||||
|
* GuideScheduleManager - Компонент для планирования рабочих смен гидов
|
||||||
|
*/
|
||||||
|
|
||||||
|
class GuideScheduleManager {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.container = options.container || document.body;
|
||||||
|
this.onScheduleChange = options.onScheduleChange || null;
|
||||||
|
this.allowMultiSelect = options.allowMultiSelect !== false;
|
||||||
|
|
||||||
|
this.currentDate = new Date();
|
||||||
|
this.currentDate.setDate(1); // Установить на первый день месяца
|
||||||
|
|
||||||
|
this.selectedGuides = new Set();
|
||||||
|
this.workingDays = new Map(); // guideId -> Set of dates
|
||||||
|
this.guides = [];
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.render();
|
||||||
|
await this.loadGuides();
|
||||||
|
this.bindEvents();
|
||||||
|
this.renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="schedule-manager">
|
||||||
|
<div class="schedule-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<h4><i class="fas fa-calendar-week me-2"></i>Планировщик рабочих смен</h4>
|
||||||
|
<div class="schedule-actions">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" id="copyPrevMonth">
|
||||||
|
<i class="fas fa-copy me-1"></i>Скопировать из прошлого месяца
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" id="copyNextMonth">
|
||||||
|
<i class="fas fa-paste me-1"></i>Скопировать в следующий месяц
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="saveSchedule">
|
||||||
|
<i class="fas fa-save me-1"></i>Сохранить изменения
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<!-- Панель выбора гидов -->
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<div class="guides-panel">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Выбор гидов</h6>
|
||||||
|
<small class="text-muted">Выберите гидов для планирования</small>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="guide-selection-actions mb-3">
|
||||||
|
<button class="btn btn-sm btn-outline-primary w-100 mb-2" id="selectAllGuides">
|
||||||
|
<i class="fas fa-check-square me-1"></i>Выбрать всех
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary w-100" id="clearGuideSelection">
|
||||||
|
<i class="fas fa-square me-1"></i>Очистить выбор
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="guidesList" class="guides-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Панель быстрых действий -->
|
||||||
|
<div class="card mt-3">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Быстрые действия</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-sm btn-success" id="markWeekdays">
|
||||||
|
<i class="fas fa-business-time me-1"></i>Отметить будни
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-warning" id="markWeekends">
|
||||||
|
<i class="fas fa-calendar-day me-1"></i>Отметить выходные
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-info" id="markFullMonth">
|
||||||
|
<i class="fas fa-calendar-check me-1"></i>Весь месяц
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" id="clearMonth">
|
||||||
|
<i class="fas fa-calendar-times me-1"></i>Очистить месяц
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Календарь -->
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<div class="calendar-container">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<button class="btn btn-outline-primary btn-sm" id="prevMonth">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<h5 class="mb-0" id="currentMonthLabel"></h5>
|
||||||
|
<button class="btn btn-outline-primary btn-sm" id="nextMonth">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div id="scheduleCalendar"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Статистика -->
|
||||||
|
<div class="schedule-stats mt-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h6 class="mb-0">Статистика рабочих дней</h6>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="scheduleStats" class="row g-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.injectStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
injectStyles() {
|
||||||
|
if (document.getElementById('schedule-manager-styles')) return;
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
<style id="schedule-manager-styles">
|
||||||
|
.schedule-manager {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox.selected {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
border-color: #2196f3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox input[type="checkbox"] {
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-info {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-specialization {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-calendar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 80px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
position: relative;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.selected {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.working {
|
||||||
|
background-color: #d4edda;
|
||||||
|
border-color: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.partial-working {
|
||||||
|
background: linear-gradient(45deg, #d4edda 50%, #fff3cd 50%);
|
||||||
|
border-color: #ffc107;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.weekend {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day.other-month {
|
||||||
|
color: #6c757d;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.working-guides {
|
||||||
|
font-size: 10px;
|
||||||
|
color: #28a745;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.working-guides .guide-initial {
|
||||||
|
display: inline-block;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 16px;
|
||||||
|
margin-right: 2px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-stats .stat-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.schedule-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
min-height: 60px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.working-guides .guide-initial {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
line-height: 12px;
|
||||||
|
font-size: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.insertAdjacentHTML('beforeend', styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadGuides() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/guides');
|
||||||
|
const data = await response.json();
|
||||||
|
this.guides = Array.isArray(data) ? data : (data.data || []);
|
||||||
|
this.renderGuidesList();
|
||||||
|
await this.loadSchedules();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки гидов:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGuidesList() {
|
||||||
|
const guidesContainer = document.getElementById('guidesList');
|
||||||
|
|
||||||
|
guidesContainer.innerHTML = this.guides.map(guide => `
|
||||||
|
<div class="guide-checkbox" data-guide-id="${guide.id}">
|
||||||
|
<label class="d-flex align-items-center guide-info">
|
||||||
|
<input type="checkbox" value="${guide.id}">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<div class="guide-name">${guide.name}</div>
|
||||||
|
<div class="guide-specialization">${guide.specialization || 'Универсальный'}</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadSchedules() {
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth() + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/guide-schedules?year=${year}&month=${month}`);
|
||||||
|
const data = await response.json();
|
||||||
|
const schedules = Array.isArray(data) ? data : (data.data || []);
|
||||||
|
|
||||||
|
this.workingDays.clear();
|
||||||
|
|
||||||
|
schedules.forEach(schedule => {
|
||||||
|
if (!this.workingDays.has(schedule.guide_id)) {
|
||||||
|
this.workingDays.set(schedule.guide_id, new Set());
|
||||||
|
}
|
||||||
|
this.workingDays.get(schedule.guide_id).add(schedule.work_date);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateStats();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки расписания:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCalendar() {
|
||||||
|
const calendar = document.getElementById('scheduleCalendar');
|
||||||
|
const monthLabel = document.getElementById('currentMonthLabel');
|
||||||
|
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
|
||||||
|
monthLabel.textContent = this.currentDate.toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long'
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstDay = new Date(year, month, 1);
|
||||||
|
const lastDay = new Date(year, month + 1, 0);
|
||||||
|
const startDate = new Date(firstDay);
|
||||||
|
startDate.setDate(startDate.getDate() - firstDay.getDay() + 1); // Начать с понедельника
|
||||||
|
|
||||||
|
const weeks = [];
|
||||||
|
let currentWeek = [];
|
||||||
|
let currentDate = new Date(startDate);
|
||||||
|
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
currentWeek.push(new Date(currentDate));
|
||||||
|
currentDate.setDate(currentDate.getDate() + 1);
|
||||||
|
|
||||||
|
if (currentWeek.length === 7) {
|
||||||
|
weeks.push(currentWeek);
|
||||||
|
currentWeek = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarHTML = `
|
||||||
|
<table class="schedule-calendar table table-bordered mb-0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="calendar-header">Пн</th>
|
||||||
|
<th class="calendar-header">Вт</th>
|
||||||
|
<th class="calendar-header">Ср</th>
|
||||||
|
<th class="calendar-header">Чт</th>
|
||||||
|
<th class="calendar-header">Пт</th>
|
||||||
|
<th class="calendar-header">Сб</th>
|
||||||
|
<th class="calendar-header">Вс</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${weeks.map(week => `
|
||||||
|
<tr>
|
||||||
|
${week.map(date => this.renderCalendarDay(date, month)).join('')}
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
|
||||||
|
calendar.innerHTML = calendarHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCalendarDay(date, currentMonth) {
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
|
||||||
|
const isCurrentMonth = date.getMonth() === currentMonth;
|
||||||
|
|
||||||
|
let classes = ['calendar-day'];
|
||||||
|
|
||||||
|
if (!isCurrentMonth) {
|
||||||
|
classes.push('other-month');
|
||||||
|
} else if (isWeekend) {
|
||||||
|
classes.push('weekend');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверить, работают ли гиды в этот день
|
||||||
|
const workingGuidesCount = this.getWorkingGuidesForDate(dateStr).length;
|
||||||
|
const selectedGuidesCount = this.selectedGuides.size;
|
||||||
|
|
||||||
|
if (workingGuidesCount > 0) {
|
||||||
|
if (selectedGuidesCount === 0 || workingGuidesCount === selectedGuidesCount) {
|
||||||
|
classes.push('working');
|
||||||
|
} else {
|
||||||
|
classes.push('partial-working');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const workingGuides = this.getWorkingGuidesForDate(dateStr);
|
||||||
|
const workingGuidesHTML = workingGuides.length > 0 ? `
|
||||||
|
<div class="working-guides">
|
||||||
|
${workingGuides.slice(0, 5).map(guide => `
|
||||||
|
<span class="guide-initial" title="${guide.name}">${guide.name.charAt(0)}</span>
|
||||||
|
`).join('')}
|
||||||
|
${workingGuides.length > 5 ? `<span title="+${workingGuides.length - 5} еще">+${workingGuides.length - 5}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<td class="${classes.join(' ')}" data-date="${dateStr}">
|
||||||
|
<div class="day-number">${date.getDate()}</div>
|
||||||
|
${workingGuidesHTML}
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getWorkingGuidesForDate(dateStr) {
|
||||||
|
const working = [];
|
||||||
|
this.workingDays.forEach((dates, guideId) => {
|
||||||
|
if (dates.has(dateStr)) {
|
||||||
|
const guide = this.guides.find(g => g.id == guideId);
|
||||||
|
if (guide) working.push(guide);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return working;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Навигация по месяцам
|
||||||
|
document.getElementById('prevMonth').addEventListener('click', () => {
|
||||||
|
this.currentDate.setMonth(this.currentDate.getMonth() - 1);
|
||||||
|
this.loadSchedules();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nextMonth').addEventListener('click', () => {
|
||||||
|
this.currentDate.setMonth(this.currentDate.getMonth() + 1);
|
||||||
|
this.loadSchedules();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Выбор гидов
|
||||||
|
document.getElementById('guidesList').addEventListener('change', (e) => {
|
||||||
|
if (e.target.type === 'checkbox') {
|
||||||
|
this.handleGuideSelection(e.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Быстрые действия
|
||||||
|
document.getElementById('selectAllGuides').addEventListener('click', () => this.selectAllGuides());
|
||||||
|
document.getElementById('clearGuideSelection').addEventListener('click', () => this.clearGuideSelection());
|
||||||
|
|
||||||
|
// Быстрое планирование
|
||||||
|
document.getElementById('markWeekdays').addEventListener('click', () => this.markWeekdays());
|
||||||
|
document.getElementById('markWeekends').addEventListener('click', () => this.markWeekends());
|
||||||
|
document.getElementById('markFullMonth').addEventListener('click', () => this.markFullMonth());
|
||||||
|
document.getElementById('clearMonth').addEventListener('click', () => this.clearMonth());
|
||||||
|
|
||||||
|
// Копирование между месяцами
|
||||||
|
document.getElementById('copyPrevMonth').addEventListener('click', () => this.copyFromPreviousMonth());
|
||||||
|
document.getElementById('copyNextMonth').addEventListener('click', () => this.copyToNextMonth());
|
||||||
|
|
||||||
|
// Сохранение
|
||||||
|
document.getElementById('saveSchedule').addEventListener('click', () => this.saveSchedule());
|
||||||
|
|
||||||
|
// Клики по дням календаря
|
||||||
|
this.container.addEventListener('click', (e) => {
|
||||||
|
const calendarDay = e.target.closest('.calendar-day');
|
||||||
|
if (calendarDay && !calendarDay.classList.contains('other-month')) {
|
||||||
|
this.handleDayClick(calendarDay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGuideSelection(checkbox) {
|
||||||
|
const guideId = parseInt(checkbox.value);
|
||||||
|
const guideCheckbox = checkbox.closest('.guide-checkbox');
|
||||||
|
|
||||||
|
if (checkbox.checked) {
|
||||||
|
this.selectedGuides.add(guideId);
|
||||||
|
guideCheckbox.classList.add('selected');
|
||||||
|
} else {
|
||||||
|
this.selectedGuides.delete(guideId);
|
||||||
|
guideCheckbox.classList.remove('selected');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAllGuides() {
|
||||||
|
const checkboxes = document.querySelectorAll('#guidesList input[type="checkbox"]');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = true;
|
||||||
|
this.handleGuideSelection(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
clearGuideSelection() {
|
||||||
|
const checkboxes = document.querySelectorAll('#guidesList input[type="checkbox"]');
|
||||||
|
checkboxes.forEach(cb => {
|
||||||
|
cb.checked = false;
|
||||||
|
this.handleGuideSelection(cb);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleDayClick(dayElement) {
|
||||||
|
if (this.selectedGuides.size === 0) {
|
||||||
|
alert('Выберите хотя бы одного гида для планирования смен');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateStr = dayElement.dataset.date;
|
||||||
|
const isWorking = dayElement.classList.contains('working') || dayElement.classList.contains('partial-working');
|
||||||
|
|
||||||
|
this.selectedGuides.forEach(guideId => {
|
||||||
|
if (!this.workingDays.has(guideId)) {
|
||||||
|
this.workingDays.set(guideId, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
const guideDates = this.workingDays.get(guideId);
|
||||||
|
|
||||||
|
if (isWorking && this.allSelectedGuidesWorkingOnDate(dateStr)) {
|
||||||
|
// Если все выбранные гиды работают в этот день, убираем их
|
||||||
|
guideDates.delete(dateStr);
|
||||||
|
} else {
|
||||||
|
// Иначе добавляем день
|
||||||
|
guideDates.add(dateStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
allSelectedGuidesWorkingOnDate(dateStr) {
|
||||||
|
for (let guideId of this.selectedGuides) {
|
||||||
|
if (!this.workingDays.has(guideId) || !this.workingDays.get(guideId).has(dateStr)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
markWeekdays() {
|
||||||
|
if (this.selectedGuides.size === 0) return;
|
||||||
|
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
|
||||||
|
if (dayOfWeek >= 1 && dayOfWeek <= 5) { // Понедельник - Пятница
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
this.selectedGuides.forEach(guideId => {
|
||||||
|
if (!this.workingDays.has(guideId)) {
|
||||||
|
this.workingDays.set(guideId, new Set());
|
||||||
|
}
|
||||||
|
this.workingDays.get(guideId).add(dateStr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
markWeekends() {
|
||||||
|
if (this.selectedGuides.size === 0) return;
|
||||||
|
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
|
||||||
|
if (dayOfWeek === 0 || dayOfWeek === 6) { // Суббота - Воскресенье
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
this.selectedGuides.forEach(guideId => {
|
||||||
|
if (!this.workingDays.has(guideId)) {
|
||||||
|
this.workingDays.set(guideId, new Set());
|
||||||
|
}
|
||||||
|
this.workingDays.get(guideId).add(dateStr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
markFullMonth() {
|
||||||
|
if (this.selectedGuides.size === 0) return;
|
||||||
|
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
this.selectedGuides.forEach(guideId => {
|
||||||
|
if (!this.workingDays.has(guideId)) {
|
||||||
|
this.workingDays.set(guideId, new Set());
|
||||||
|
}
|
||||||
|
this.workingDays.get(guideId).add(dateStr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
clearMonth() {
|
||||||
|
if (this.selectedGuides.size === 0) return;
|
||||||
|
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, month, day);
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
this.selectedGuides.forEach(guideId => {
|
||||||
|
if (this.workingDays.has(guideId)) {
|
||||||
|
this.workingDays.get(guideId).delete(dateStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyFromPreviousMonth() {
|
||||||
|
const prevMonth = new Date(this.currentDate);
|
||||||
|
prevMonth.setMonth(prevMonth.getMonth() - 1);
|
||||||
|
|
||||||
|
const year = prevMonth.getFullYear();
|
||||||
|
const month = prevMonth.getMonth() + 1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/guide-schedules?year=${year}&month=${month}`);
|
||||||
|
const data = await response.json();
|
||||||
|
const schedules = Array.isArray(data) ? data : (data.data || []);
|
||||||
|
|
||||||
|
// Копируем расписание из предыдущего месяца в текущий
|
||||||
|
schedules.forEach(schedule => {
|
||||||
|
const prevDate = new Date(schedule.work_date);
|
||||||
|
const currentMonthDate = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), prevDate.getDate());
|
||||||
|
|
||||||
|
// Проверяем, что день существует в текущем месяце
|
||||||
|
if (currentMonthDate.getMonth() === this.currentDate.getMonth()) {
|
||||||
|
const dateStr = currentMonthDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
if (!this.workingDays.has(schedule.guide_id)) {
|
||||||
|
this.workingDays.set(schedule.guide_id, new Set());
|
||||||
|
}
|
||||||
|
this.workingDays.get(schedule.guide_id).add(dateStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateStats();
|
||||||
|
|
||||||
|
alert('Расписание скопировано из предыдущего месяца');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка копирования расписания:', error);
|
||||||
|
alert('Ошибка при копировании расписания');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyToNextMonth() {
|
||||||
|
// Сначала сохраняем текущие изменения
|
||||||
|
await this.saveSchedule(false);
|
||||||
|
|
||||||
|
const nextMonth = new Date(this.currentDate);
|
||||||
|
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||||
|
|
||||||
|
const scheduleData = [];
|
||||||
|
const year = nextMonth.getFullYear();
|
||||||
|
const month = nextMonth.getMonth();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
// Создаем расписание для следующего месяца
|
||||||
|
this.workingDays.forEach((dates, guideId) => {
|
||||||
|
dates.forEach(dateStr => {
|
||||||
|
const currentDate = new Date(dateStr);
|
||||||
|
const day = currentDate.getDate();
|
||||||
|
|
||||||
|
// Проверяем, что день существует в следующем месяце
|
||||||
|
if (day <= daysInMonth) {
|
||||||
|
const nextMonthDate = new Date(year, month, day);
|
||||||
|
const nextDateStr = nextMonthDate.toISOString().split('T')[0];
|
||||||
|
|
||||||
|
scheduleData.push({
|
||||||
|
guide_id: guideId,
|
||||||
|
work_date: nextDateStr
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/guide-schedules/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schedules: scheduleData })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Расписание скопировано в следующий месяц');
|
||||||
|
} else {
|
||||||
|
throw new Error('Ошибка сохранения');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка копирования расписания:', error);
|
||||||
|
alert('Ошибка при копировании расписания в следующий месяц');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveSchedule(showAlert = true) {
|
||||||
|
const scheduleData = [];
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth() + 1;
|
||||||
|
|
||||||
|
this.workingDays.forEach((dates, guideId) => {
|
||||||
|
dates.forEach(dateStr => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
if (date.getFullYear() === year && date.getMonth() + 1 === month) {
|
||||||
|
scheduleData.push({
|
||||||
|
guide_id: guideId,
|
||||||
|
work_date: dateStr
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/guide-schedules?year=${year}&month=${month}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ schedules: scheduleData })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
if (showAlert) {
|
||||||
|
alert('Расписание сохранено успешно');
|
||||||
|
}
|
||||||
|
if (this.onScheduleChange) {
|
||||||
|
this.onScheduleChange(scheduleData);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Ошибка сохранения');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка сохранения расписания:', error);
|
||||||
|
if (showAlert) {
|
||||||
|
alert('Ошибка при сохранении расписания');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStats() {
|
||||||
|
const statsContainer = document.getElementById('scheduleStats');
|
||||||
|
const year = this.currentDate.getFullYear();
|
||||||
|
const month = this.currentDate.getMonth();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
|
||||||
|
// Подсчет статистики
|
||||||
|
const stats = {
|
||||||
|
totalGuides: this.guides.length,
|
||||||
|
activeGuides: 0,
|
||||||
|
totalWorkingDays: 0,
|
||||||
|
averageWorkingDays: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const guideWorkingDays = new Map();
|
||||||
|
|
||||||
|
this.workingDays.forEach((dates, guideId) => {
|
||||||
|
const currentMonthDays = Array.from(dates).filter(dateStr => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.getFullYear() === year && date.getMonth() === month;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentMonthDays.length > 0) {
|
||||||
|
stats.activeGuides++;
|
||||||
|
guideWorkingDays.set(guideId, currentMonthDays.length);
|
||||||
|
stats.totalWorkingDays += currentMonthDays.length;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
stats.averageWorkingDays = stats.activeGuides > 0 ?
|
||||||
|
Math.round(stats.totalWorkingDays / stats.activeGuides * 10) / 10 : 0;
|
||||||
|
|
||||||
|
const coverage = stats.activeGuides > 0 ?
|
||||||
|
Math.round((stats.totalWorkingDays / (daysInMonth * stats.activeGuides)) * 100) : 0;
|
||||||
|
|
||||||
|
statsContainer.innerHTML = `
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number text-primary">${stats.totalGuides}</div>
|
||||||
|
<div class="stat-label">Всего гидов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number text-success">${stats.activeGuides}</div>
|
||||||
|
<div class="stat-label">Активных гидов</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number text-info">${stats.averageWorkingDays}</div>
|
||||||
|
<div class="stat-label">Ср. дней/гид</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="stat-item">
|
||||||
|
<div class="stat-number text-warning">${coverage}%</div>
|
||||||
|
<div class="stat-label">Покрытие месяца</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.GuideScheduleManager = GuideScheduleManager;
|
||||||
|
}
|
||||||
639
public/components/guide-selector.js
Normal file
639
public/components/guide-selector.js
Normal file
@@ -0,0 +1,639 @@
|
|||||||
|
/**
|
||||||
|
* GuideSelector - Компонент для выбора гида
|
||||||
|
* Используется в формах бронирования и админке
|
||||||
|
*/
|
||||||
|
|
||||||
|
class GuideSelector {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.container = options.container || document.body;
|
||||||
|
this.mode = options.mode || 'booking'; // 'booking', 'admin', 'simple'
|
||||||
|
this.selectedDate = options.selectedDate || null;
|
||||||
|
this.selectedGuideId = options.selectedGuideId || null;
|
||||||
|
this.onGuideSelect = options.onGuideSelect || null;
|
||||||
|
this.onDateChange = options.onDateChange || null;
|
||||||
|
this.showAvailabilityOnly = options.showAvailabilityOnly !== false;
|
||||||
|
this.multiple = options.multiple || false;
|
||||||
|
this.placeholder = options.placeholder || 'Выберите гида';
|
||||||
|
|
||||||
|
this.guides = [];
|
||||||
|
this.schedules = [];
|
||||||
|
this.holidays = [];
|
||||||
|
this.bookings = [];
|
||||||
|
this.filteredGuides = [];
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
this.render();
|
||||||
|
await this.loadData();
|
||||||
|
this.updateGuidesList();
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const modeClass = `guide-selector-${this.mode}`;
|
||||||
|
const multipleClass = this.multiple ? 'guide-selector-multiple' : '';
|
||||||
|
|
||||||
|
this.container.innerHTML = `
|
||||||
|
<div class="guide-selector ${modeClass} ${multipleClass}">
|
||||||
|
${this.mode === 'booking' ? `
|
||||||
|
<div class="selector-header">
|
||||||
|
<h4>Выбор гида</h4>
|
||||||
|
<p class="selector-subtitle">Выберите подходящего гида для вашего тура</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="selector-controls">
|
||||||
|
${this.showAvailabilityOnly ? `
|
||||||
|
<div class="date-filter">
|
||||||
|
<label for="dateInput-${this.getId()}">Дата тура:</label>
|
||||||
|
<input type="date"
|
||||||
|
id="dateInput-${this.getId()}"
|
||||||
|
value="${this.selectedDate || ''}"
|
||||||
|
min="${new Date().toISOString().split('T')[0]}">
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="availability-filter">
|
||||||
|
<label>
|
||||||
|
<input type="checkbox"
|
||||||
|
id="availabilityFilter-${this.getId()}"
|
||||||
|
${this.showAvailabilityOnly ? 'checked' : ''}>
|
||||||
|
Только доступные гиды
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="guides-list" id="guidesList-${this.getId()}">
|
||||||
|
<div class="loading">Загрузка гидов...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.multiple ? `
|
||||||
|
<div class="selected-guides" id="selectedGuides-${this.getId()}" style="display: none;">
|
||||||
|
<h5>Выбранные гиды:</h5>
|
||||||
|
<div class="selected-guides-list"></div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.injectStyles();
|
||||||
|
}
|
||||||
|
|
||||||
|
getId() {
|
||||||
|
if (!this._id) {
|
||||||
|
this._id = 'guide-selector-' + Math.random().toString(36).substr(2, 9);
|
||||||
|
}
|
||||||
|
return this._id;
|
||||||
|
}
|
||||||
|
|
||||||
|
injectStyles() {
|
||||||
|
if (document.getElementById('guide-selector-styles')) return;
|
||||||
|
|
||||||
|
const styles = `
|
||||||
|
<style id="guide-selector-styles">
|
||||||
|
.guide-selector {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-header {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-header h4 {
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
align-items: end;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter input[type="date"] {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-filter input[type="date"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-filter {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.availability-filter label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guides-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-card {
|
||||||
|
padding: 15px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: white;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-card:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 123, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-card.selected {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #f0f8ff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-card.unavailable {
|
||||||
|
opacity: 0.6;
|
||||||
|
background: #f8f9fa;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-card.unavailable:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-specialization {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-status {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-available {
|
||||||
|
background: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-unavailable {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-busy {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-info {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-info strong {
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-rate {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #28a745;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-guides {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #e3f2fd;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-guides h5 {
|
||||||
|
margin: 0 0 10px 0;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-guides-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-guide-tag {
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-guide {
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-guide:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Режимы */
|
||||||
|
.guide-selector-simple .guides-list {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-selector-simple .guide-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
padding: 10px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-selector-simple .guide-details {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-selector-admin .selector-header {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.selector-controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-details {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.head.insertAdjacentHTML('beforeend', styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
const dateInput = this.container.querySelector(`#dateInput-${this.getId()}`);
|
||||||
|
const availabilityFilter = this.container.querySelector(`#availabilityFilter-${this.getId()}`);
|
||||||
|
|
||||||
|
if (dateInput) {
|
||||||
|
dateInput.addEventListener('change', (e) => {
|
||||||
|
this.selectedDate = e.target.value;
|
||||||
|
this.updateGuidesList();
|
||||||
|
if (this.onDateChange) {
|
||||||
|
this.onDateChange(this.selectedDate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availabilityFilter) {
|
||||||
|
availabilityFilter.addEventListener('change', (e) => {
|
||||||
|
this.showAvailabilityOnly = e.target.checked;
|
||||||
|
this.updateGuidesList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.container.addEventListener('click', (e) => {
|
||||||
|
const guideCard = e.target.closest('.guide-card');
|
||||||
|
if (guideCard && !guideCard.classList.contains('unavailable')) {
|
||||||
|
const guideId = parseInt(guideCard.dataset.guideId);
|
||||||
|
this.selectGuide(guideId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeBtn = e.target.closest('.remove-guide');
|
||||||
|
if (removeBtn) {
|
||||||
|
const guideId = parseInt(removeBtn.dataset.guideId);
|
||||||
|
this.deselectGuide(guideId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
try {
|
||||||
|
const [guidesRes, schedulesRes, holidaysRes, bookingsRes] = await Promise.all([
|
||||||
|
fetch('/api/guides'),
|
||||||
|
fetch('/api/guide-schedules'),
|
||||||
|
fetch('/api/holidays'),
|
||||||
|
fetch('/api/bookings')
|
||||||
|
]);
|
||||||
|
|
||||||
|
const guidesData = await guidesRes.json();
|
||||||
|
const schedulesData = await schedulesRes.json();
|
||||||
|
const holidaysData = await holidaysRes.json();
|
||||||
|
const bookingsData = await bookingsRes.json();
|
||||||
|
|
||||||
|
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || []);
|
||||||
|
this.schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData.data || []);
|
||||||
|
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || []);
|
||||||
|
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || []);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки данных:', error);
|
||||||
|
this.showError('Ошибка загрузки данных');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateGuidesList() {
|
||||||
|
const listContainer = this.container.querySelector(`#guidesList-${this.getId()}`);
|
||||||
|
if (!listContainer) return;
|
||||||
|
|
||||||
|
if (!this.guides || this.guides.length === 0) {
|
||||||
|
listContainer.innerHTML = '<div class="error">Нет доступных гидов</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filteredGuides = this.guides.filter(guide => {
|
||||||
|
if (!this.showAvailabilityOnly) return true;
|
||||||
|
if (!this.selectedDate) return true;
|
||||||
|
|
||||||
|
const status = this.getGuideStatus(guide.id, this.selectedDate);
|
||||||
|
return status === 'working';
|
||||||
|
});
|
||||||
|
|
||||||
|
if (this.filteredGuides.length === 0) {
|
||||||
|
listContainer.innerHTML = `
|
||||||
|
<div class="error">
|
||||||
|
${this.selectedDate ?
|
||||||
|
'Нет доступных гидов на выбранную дату. Попробуйте другую дату.' :
|
||||||
|
'Нет доступных гидов'
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listContainer.innerHTML = this.filteredGuides.map(guide => this.renderGuideCard(guide)).join('');
|
||||||
|
|
||||||
|
if (this.multiple) {
|
||||||
|
this.updateSelectedGuidesList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGuideCard(guide) {
|
||||||
|
const status = this.selectedDate ? this.getGuideStatus(guide.id, this.selectedDate) : 'working';
|
||||||
|
const isSelected = this.multiple ?
|
||||||
|
this.selectedGuideIds.includes(guide.id) :
|
||||||
|
this.selectedGuideId === guide.id;
|
||||||
|
|
||||||
|
const statusClass = status === 'working' ? 'available' : 'unavailable';
|
||||||
|
const cardClass = status === 'working' ? '' : 'unavailable';
|
||||||
|
const selectedClass = isSelected ? 'selected' : '';
|
||||||
|
|
||||||
|
const specializations = {
|
||||||
|
'city': 'Городские туры',
|
||||||
|
'mountain': 'Горные походы',
|
||||||
|
'fishing': 'Рыбалка',
|
||||||
|
'general': 'Универсальный'
|
||||||
|
};
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="guide-card ${cardClass} ${selectedClass}" data-guide-id="${guide.id}">
|
||||||
|
<div class="guide-header">
|
||||||
|
<div>
|
||||||
|
<h4 class="guide-name">${guide.name}</h4>
|
||||||
|
<p class="guide-specialization">${specializations[guide.specialization] || guide.specialization}</p>
|
||||||
|
</div>
|
||||||
|
<span class="guide-status status-${statusClass}">
|
||||||
|
${status === 'working' ? 'Доступен' : status === 'busy' ? 'Занят' : 'Выходной'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${this.mode !== 'simple' ? `
|
||||||
|
<div class="guide-details">
|
||||||
|
<div class="guide-info">
|
||||||
|
<strong>Опыт:</strong> ${guide.experience || 'Не указан'} лет
|
||||||
|
</div>
|
||||||
|
<div class="guide-info">
|
||||||
|
<strong>Языки:</strong> ${guide.languages || 'Не указаны'}
|
||||||
|
</div>
|
||||||
|
<div class="guide-info">
|
||||||
|
<strong>Email:</strong> ${guide.email || 'Не указан'}
|
||||||
|
</div>
|
||||||
|
<div class="guide-rate">
|
||||||
|
${guide.hourly_rate ? `${guide.hourly_rate}₩/час` : 'Цена договорная'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGuideStatus(guideId, dateStr) {
|
||||||
|
if (!dateStr) return 'working';
|
||||||
|
|
||||||
|
// Проверяем выходные дни
|
||||||
|
const holiday = this.holidays.find(h =>
|
||||||
|
h.guide_id === guideId && h.holiday_date === dateStr
|
||||||
|
);
|
||||||
|
if (holiday) return 'holiday';
|
||||||
|
|
||||||
|
// Проверяем бронирования
|
||||||
|
const booking = this.bookings.find(b =>
|
||||||
|
b.guide_id === guideId &&
|
||||||
|
new Date(b.preferred_date).toISOString().split('T')[0] === dateStr
|
||||||
|
);
|
||||||
|
if (booking) return 'busy';
|
||||||
|
|
||||||
|
return 'working';
|
||||||
|
}
|
||||||
|
|
||||||
|
selectGuide(guideId) {
|
||||||
|
if (this.multiple) {
|
||||||
|
if (!this.selectedGuideIds) {
|
||||||
|
this.selectedGuideIds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.selectedGuideIds.includes(guideId)) {
|
||||||
|
this.selectedGuideIds.push(guideId);
|
||||||
|
this.updateGuidesList();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.selectedGuideId = guideId;
|
||||||
|
this.updateGuidesList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onGuideSelect) {
|
||||||
|
const selectedGuides = this.multiple ?
|
||||||
|
this.guides.filter(g => this.selectedGuideIds.includes(g.id)) :
|
||||||
|
this.guides.find(g => g.id === guideId);
|
||||||
|
this.onGuideSelect(selectedGuides);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectGuide(guideId) {
|
||||||
|
if (this.multiple && this.selectedGuideIds) {
|
||||||
|
this.selectedGuideIds = this.selectedGuideIds.filter(id => id !== guideId);
|
||||||
|
this.updateGuidesList();
|
||||||
|
|
||||||
|
if (this.onGuideSelect) {
|
||||||
|
const selectedGuides = this.guides.filter(g => this.selectedGuideIds.includes(g.id));
|
||||||
|
this.onGuideSelect(selectedGuides);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelectedGuidesList() {
|
||||||
|
if (!this.multiple) return;
|
||||||
|
|
||||||
|
const selectedContainer = this.container.querySelector(`#selectedGuides-${this.getId()}`);
|
||||||
|
if (!selectedContainer) return;
|
||||||
|
|
||||||
|
if (!this.selectedGuideIds || this.selectedGuideIds.length === 0) {
|
||||||
|
selectedContainer.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedContainer.style.display = 'block';
|
||||||
|
const listEl = selectedContainer.querySelector('.selected-guides-list');
|
||||||
|
|
||||||
|
listEl.innerHTML = this.selectedGuideIds.map(guideId => {
|
||||||
|
const guide = this.guides.find(g => g.id === guideId);
|
||||||
|
return `
|
||||||
|
<span class="selected-guide-tag">
|
||||||
|
${guide.name}
|
||||||
|
<span class="remove-guide" data-guide-id="${guideId}">×</span>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
const listContainer = this.container.querySelector(`#guidesList-${this.getId()}`);
|
||||||
|
if (listContainer) {
|
||||||
|
listContainer.innerHTML = `<div class="error">${message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Публичные методы
|
||||||
|
setDate(dateStr) {
|
||||||
|
this.selectedDate = dateStr;
|
||||||
|
const dateInput = this.container.querySelector(`#dateInput-${this.getId()}`);
|
||||||
|
if (dateInput) {
|
||||||
|
dateInput.value = dateStr;
|
||||||
|
}
|
||||||
|
this.updateGuidesList();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSelectedGuides() {
|
||||||
|
if (this.multiple) {
|
||||||
|
return this.guides.filter(g => this.selectedGuideIds && this.selectedGuideIds.includes(g.id));
|
||||||
|
} else {
|
||||||
|
return this.guides.find(g => g.id === this.selectedGuideId) || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAvailableGuides(dateStr = null) {
|
||||||
|
const date = dateStr || this.selectedDate;
|
||||||
|
if (!date) return this.guides;
|
||||||
|
|
||||||
|
return this.guides.filter(guide =>
|
||||||
|
this.getGuideStatus(guide.id, date) === 'working'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh() {
|
||||||
|
this.loadData().then(() => {
|
||||||
|
this.updateGuidesList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Экспорт
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = GuideSelector;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.GuideSelector = GuideSelector;
|
||||||
|
}
|
||||||
@@ -15,6 +15,46 @@
|
|||||||
background: linear-gradient(180deg, #1f2937 0%, #111827 100%) !important;
|
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 {
|
.nav-sidebar .nav-item > .nav-link {
|
||||||
color: #d1d5db !important;
|
color: #d1d5db !important;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
@@ -302,3 +342,64 @@ input[name*="avatar"]:focus {
|
|||||||
max-width: 100% !important;
|
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;
|
||||||
|
}
|
||||||
3
public/css/custom-styles.css
Normal file
3
public/css/custom-styles.css
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/* Автоматически сгенерированные стили - 2025-11-30T02:42:19.565Z */
|
||||||
|
|
||||||
|
:root { --primary-color: #ff6b6b; --secondary-color: #38C172; }
|
||||||
557
public/guide-calendar.html
Normal file
557
public/guide-calendar.html
Normal file
@@ -0,0 +1,557 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Календарь гидов</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
background: #2d3748;
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-navigation {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-button:hover {
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-month {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
min-width: 200px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-content {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guides-filter {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox:hover {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox input[type="checkbox"] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-checkbox.checked {
|
||||||
|
border-color: #ff6b6b;
|
||||||
|
background: #fff5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
background: #dee2e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-header {
|
||||||
|
background: #6c757d;
|
||||||
|
color: white;
|
||||||
|
padding: 10px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
background: white;
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 8px;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.other-month {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.today {
|
||||||
|
background: #fff3cd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
color: #495057;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.other-month .day-number {
|
||||||
|
color: #adb5bd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-schedule {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item {
|
||||||
|
background: #e9ecef;
|
||||||
|
color: #495057;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item.working {
|
||||||
|
background: #d1f2eb;
|
||||||
|
color: #00875a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item.holiday {
|
||||||
|
background: #ffcdd2;
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item.partial {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.working { background: #d1f2eb; }
|
||||||
|
.holiday { background: #ffcdd2; }
|
||||||
|
.partial { background: #fff3cd; }
|
||||||
|
.unavailable { background: #e9ecef; }
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #dc3545;
|
||||||
|
background: #f8d7da;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.calendar-header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guides-filter {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-item {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="calendar-container">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<h1 class="calendar-title">📅 Календарь работы гидов</h1>
|
||||||
|
<div class="month-navigation">
|
||||||
|
<button class="nav-button" onclick="changeMonth(-1)">‹</button>
|
||||||
|
<div class="current-month" id="currentMonth"></div>
|
||||||
|
<button class="nav-button" onclick="changeMonth(1)">›</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-content">
|
||||||
|
<div class="guides-filter">
|
||||||
|
<span class="filter-label">Показать гидов:</span>
|
||||||
|
<div id="guidesFilter"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="calendarGrid"></div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color working"></div>
|
||||||
|
<span>Рабочий день</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color partial"></div>
|
||||||
|
<span>Частично доступен</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color holiday"></div>
|
||||||
|
<span>Выходной</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color unavailable"></div>
|
||||||
|
<span>Не работает</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
class GuideCalendar {
|
||||||
|
constructor() {
|
||||||
|
this.currentDate = new Date();
|
||||||
|
this.guides = [];
|
||||||
|
this.schedules = [];
|
||||||
|
this.holidays = [];
|
||||||
|
this.selectedGuides = new Set();
|
||||||
|
this.bookings = [];
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
await this.loadData();
|
||||||
|
this.renderGuidesFilter();
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateMonthDisplay();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadData() {
|
||||||
|
try {
|
||||||
|
// Загружаем гидов
|
||||||
|
const guidesResponse = await fetch('/api/guides');
|
||||||
|
const guidesData = await guidesResponse.json();
|
||||||
|
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || guidesData.guides || []);
|
||||||
|
|
||||||
|
// Загружаем расписания
|
||||||
|
const schedulesResponse = await fetch('/api/guide-schedules');
|
||||||
|
const schedulesData = await schedulesResponse.json();
|
||||||
|
this.schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData.data || schedulesData.schedules || []);
|
||||||
|
|
||||||
|
// Загружаем выходные дни
|
||||||
|
const holidaysResponse = await fetch('/api/holidays');
|
||||||
|
const holidaysData = await holidaysResponse.json();
|
||||||
|
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || holidaysData.holidays || []);
|
||||||
|
|
||||||
|
// Загружаем существующие бронирования
|
||||||
|
const bookingsResponse = await fetch('/api/bookings');
|
||||||
|
const bookingsData = await bookingsResponse.json();
|
||||||
|
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || bookingsData.bookings || []);
|
||||||
|
|
||||||
|
// По умолчанию показываем всех гидов
|
||||||
|
if (this.guides && this.guides.length > 0) {
|
||||||
|
this.guides.forEach(guide => this.selectedGuides.add(guide.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки данных:', error);
|
||||||
|
document.getElementById('calendarGrid').innerHTML =
|
||||||
|
'<div class="error">Ошибка загрузки данных календаря</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGuidesFilter() {
|
||||||
|
const filterContainer = document.getElementById('guidesFilter');
|
||||||
|
filterContainer.innerHTML = '';
|
||||||
|
|
||||||
|
if (!this.guides || !Array.isArray(this.guides)) {
|
||||||
|
filterContainer.innerHTML = '<div class="error">Нет доступных гидов</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.guides.forEach(guide => {
|
||||||
|
const checkbox = document.createElement('label');
|
||||||
|
checkbox.className = 'guide-checkbox';
|
||||||
|
if (this.selectedGuides.has(guide.id)) {
|
||||||
|
checkbox.classList.add('checked');
|
||||||
|
}
|
||||||
|
|
||||||
|
checkbox.innerHTML = `
|
||||||
|
<input type="checkbox"
|
||||||
|
${this.selectedGuides.has(guide.id) ? 'checked' : ''}
|
||||||
|
onchange="calendar.toggleGuide(${guide.id})">
|
||||||
|
<span>${guide.name}</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
filterContainer.appendChild(checkbox);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleGuide(guideId) {
|
||||||
|
if (this.selectedGuides.has(guideId)) {
|
||||||
|
this.selectedGuides.delete(guideId);
|
||||||
|
} else {
|
||||||
|
this.selectedGuides.add(guideId);
|
||||||
|
}
|
||||||
|
this.renderGuidesFilter();
|
||||||
|
this.renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderCalendar() {
|
||||||
|
const grid = document.getElementById('calendarGrid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
grid.className = 'calendar-grid';
|
||||||
|
|
||||||
|
// Заголовки дней недели
|
||||||
|
const dayHeaders = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
dayHeaders.forEach(day => {
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'day-header';
|
||||||
|
header.textContent = day;
|
||||||
|
grid.appendChild(header);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Дни месяца
|
||||||
|
const firstDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth(), 1);
|
||||||
|
const lastDay = new Date(this.currentDate.getFullYear(), this.currentDate.getMonth() + 1, 0);
|
||||||
|
|
||||||
|
// Начинаем с понедельника
|
||||||
|
const startDate = new Date(firstDay);
|
||||||
|
const dayOfWeek = firstDay.getDay();
|
||||||
|
const daysToSubtract = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
||||||
|
startDate.setDate(startDate.getDate() - daysToSubtract);
|
||||||
|
|
||||||
|
// Генерируем 42 дня (6 недель)
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const currentDay = new Date(startDate);
|
||||||
|
currentDay.setDate(startDate.getDate() + i);
|
||||||
|
|
||||||
|
const dayCell = this.createDayCell(currentDay);
|
||||||
|
grid.appendChild(dayCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createDayCell(date) {
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'day-cell';
|
||||||
|
|
||||||
|
const isCurrentMonth = date.getMonth() === this.currentDate.getMonth();
|
||||||
|
const isToday = this.isToday(date);
|
||||||
|
|
||||||
|
if (!isCurrentMonth) {
|
||||||
|
cell.classList.add('other-month');
|
||||||
|
}
|
||||||
|
if (isToday) {
|
||||||
|
cell.classList.add('today');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dayNumber = document.createElement('div');
|
||||||
|
dayNumber.className = 'day-number';
|
||||||
|
dayNumber.textContent = date.getDate();
|
||||||
|
cell.appendChild(dayNumber);
|
||||||
|
|
||||||
|
const scheduleContainer = document.createElement('div');
|
||||||
|
scheduleContainer.className = 'guide-schedule';
|
||||||
|
|
||||||
|
// Добавляем информацию о гидах для этого дня
|
||||||
|
this.guides.forEach(guide => {
|
||||||
|
if (this.selectedGuides.has(guide.id)) {
|
||||||
|
const guideStatus = this.getGuideStatusForDate(guide, date);
|
||||||
|
const guideItem = document.createElement('div');
|
||||||
|
guideItem.className = `guide-item ${guideStatus.type}`;
|
||||||
|
guideItem.textContent = `${guide.name.split(' ')[0]} ${guideStatus.time}`;
|
||||||
|
guideItem.title = `${guide.name} - ${guideStatus.description}`;
|
||||||
|
|
||||||
|
scheduleContainer.appendChild(guideItem);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
cell.appendChild(scheduleContainer);
|
||||||
|
return cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
getGuideStatusForDate(guide, date) {
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
const dayName = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'][dayOfWeek];
|
||||||
|
|
||||||
|
// Проверяем выходные дни
|
||||||
|
const holiday = this.holidays.find(h =>
|
||||||
|
h.guide_id === guide.id &&
|
||||||
|
new Date(h.date).toDateString() === date.toDateString()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (holiday) {
|
||||||
|
return {
|
||||||
|
type: 'holiday',
|
||||||
|
time: '',
|
||||||
|
description: holiday.title
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем расписание
|
||||||
|
const schedule = this.schedules.find(s => s.guide_id === guide.id);
|
||||||
|
|
||||||
|
if (!schedule || !schedule[dayName]) {
|
||||||
|
return {
|
||||||
|
type: 'unavailable',
|
||||||
|
time: '',
|
||||||
|
description: 'Не работает'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем существующие бронирования
|
||||||
|
const dateStr = date.toISOString().split('T')[0];
|
||||||
|
const dayBookings = this.bookings.filter(b =>
|
||||||
|
b.guide_id === guide.id &&
|
||||||
|
b.booking_date === dateStr
|
||||||
|
);
|
||||||
|
|
||||||
|
const startTime = schedule.start_time || '09:00';
|
||||||
|
const endTime = schedule.end_time || '18:00';
|
||||||
|
|
||||||
|
if (dayBookings.length > 0) {
|
||||||
|
// Если есть бронирования, показываем частично доступен
|
||||||
|
return {
|
||||||
|
type: 'partial',
|
||||||
|
time: `${startTime}-${endTime}`,
|
||||||
|
description: `Рабочий день (${dayBookings.length} бронирований)`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'working',
|
||||||
|
time: `${startTime}-${endTime}`,
|
||||||
|
description: `Рабочий день ${startTime}-${endTime}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
isToday(date) {
|
||||||
|
const today = new Date();
|
||||||
|
return date.toDateString() === today.toDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMonthDisplay() {
|
||||||
|
const monthNames = [
|
||||||
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||||
|
];
|
||||||
|
|
||||||
|
const monthDisplay = document.getElementById('currentMonth');
|
||||||
|
monthDisplay.textContent = `${monthNames[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
changeMonth(delta) {
|
||||||
|
this.currentDate.setMonth(this.currentDate.getMonth() + delta);
|
||||||
|
this.renderCalendar();
|
||||||
|
this.updateMonthDisplay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация календаря
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
calendar = new GuideCalendar();
|
||||||
|
|
||||||
|
// Добавляем обработчики событий для кнопок навигации
|
||||||
|
const prevBtn = document.getElementById('prevMonth');
|
||||||
|
const nextBtn = document.getElementById('nextMonth');
|
||||||
|
|
||||||
|
if (prevBtn) {
|
||||||
|
prevBtn.addEventListener('click', () => calendar.changeMonth(-1));
|
||||||
|
}
|
||||||
|
if (nextBtn) {
|
||||||
|
nextBtn.addEventListener('click', () => calendar.changeMonth(1));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
953
public/image-manager.html
Normal file
953
public/image-manager.html
Normal file
@@ -0,0 +1,953 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Менеджер изображений</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
color: #333;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #6c757d;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Загрузка файлов */
|
||||||
|
.upload-area {
|
||||||
|
background: white;
|
||||||
|
border: 2px dashed #ddd;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #f8f9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area.dragover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #f0f4ff;
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #ddd;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover .upload-icon {
|
||||||
|
color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-subtext {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Фильтры и поиск */
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 250px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 40px 12px 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #667eea;
|
||||||
|
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
right: 12px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: 10px 15px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: white;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: none;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Сетка изображений */
|
||||||
|
.images-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.images-list {
|
||||||
|
display: none;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder {
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-card:hover .image-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-btn {
|
||||||
|
padding: 8px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay-btn:hover {
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-info {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-name {
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-details {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6c757d;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-url {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c757d;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Модальные окна */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.show {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 90%;
|
||||||
|
overflow: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
object-fit: contain;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Прогресс загрузки */
|
||||||
|
.upload-progress {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 3px;
|
||||||
|
transition: width 0.3s;
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Уведомления */
|
||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 1001;
|
||||||
|
transform: translateX(100%);
|
||||||
|
transition: transform 0.3s;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.success {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.error {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.info {
|
||||||
|
background: #17a2b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скелетон загрузки */
|
||||||
|
.skeleton {
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: loading 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes loading {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text {
|
||||||
|
height: 16px;
|
||||||
|
margin: 15px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-text.short {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптив */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.container {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-group {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.images-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Заголовок -->
|
||||||
|
<div class="header">
|
||||||
|
<h1>🖼️ Менеджер изображений</h1>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-secondary" onclick="refreshGallery()">
|
||||||
|
🔄 Обновить
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" onclick="openUpload()">
|
||||||
|
📤 Загрузить изображения
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Область загрузки -->
|
||||||
|
<div class="upload-area" id="uploadArea" onclick="openUpload()">
|
||||||
|
<div class="upload-icon">📸</div>
|
||||||
|
<div class="upload-text">Перетащите изображения сюда или нажмите для выбора</div>
|
||||||
|
<div class="upload-subtext">Поддерживаются JPG, PNG, GIF, WEBP до 10MB</div>
|
||||||
|
<input type="file" id="fileInput" multiple accept="image/*" style="display: none;" onchange="handleFileUpload(this.files)">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Прогресс загрузки -->
|
||||||
|
<div class="upload-progress" id="uploadProgress" style="display: none;">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" id="progressFill"></div>
|
||||||
|
</div>
|
||||||
|
<div class="progress-text" id="progressText">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Контролы -->
|
||||||
|
<div class="controls">
|
||||||
|
<div class="search-box">
|
||||||
|
<input type="text" class="search-input" placeholder="Поиск по названию или типу..."
|
||||||
|
oninput="searchImages(this.value)">
|
||||||
|
<span class="search-icon">🔍</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filter-group">
|
||||||
|
<select class="filter-select" onchange="filterByType(this.value)">
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
<option value="routes">Маршруты</option>
|
||||||
|
<option value="guides">Гиды</option>
|
||||||
|
<option value="articles">Статьи</option>
|
||||||
|
<option value="general">Общие</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button class="view-btn active" onclick="setView('grid')" data-view="grid">⊞</button>
|
||||||
|
<button class="view-btn" onclick="setView('list')" data-view="list">≡</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Сетка изображений -->
|
||||||
|
<div class="images-grid" id="imagesGrid">
|
||||||
|
<!-- Скелетоны загрузки -->
|
||||||
|
<div class="skeleton-card">
|
||||||
|
<div class="skeleton skeleton-image"></div>
|
||||||
|
<div class="skeleton skeleton-text"></div>
|
||||||
|
<div class="skeleton skeleton-text short"></div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-card">
|
||||||
|
<div class="skeleton skeleton-image"></div>
|
||||||
|
<div class="skeleton skeleton-text"></div>
|
||||||
|
<div class="skeleton skeleton-text short"></div>
|
||||||
|
</div>
|
||||||
|
<div class="skeleton-card">
|
||||||
|
<div class="skeleton skeleton-image"></div>
|
||||||
|
<div class="skeleton skeleton-text"></div>
|
||||||
|
<div class="skeleton skeleton-text short"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Список изображений (альтернативный вид) -->
|
||||||
|
<div class="images-list" id="imagesList">
|
||||||
|
<!-- Будет заполнено динамически -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно просмотра -->
|
||||||
|
<div class="modal" id="viewModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">Просмотр изображения</h3>
|
||||||
|
<button class="close-btn" onclick="closeModal('viewModal')">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<img class="preview-image" id="previewImage" src="" alt="">
|
||||||
|
<div style="margin-top: 20px;">
|
||||||
|
<p><strong>Имя файла:</strong> <span id="fileName"></span></p>
|
||||||
|
<p><strong>Размер:</strong> <span id="fileSize"></span></p>
|
||||||
|
<p><strong>URL:</strong> <span id="fileUrl" style="font-family: monospace; background: #f8f9fa; padding: 2px 6px; border-radius: 4px;"></span></p>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 20px; text-align: center;">
|
||||||
|
<button class="btn btn-primary" onclick="copyToClipboard()">📋 Копировать URL</button>
|
||||||
|
<button class="btn btn-secondary" onclick="downloadImage()">💾 Скачать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Уведомления -->
|
||||||
|
<div id="notification" class="notification"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentImages = [];
|
||||||
|
let filteredImages = [];
|
||||||
|
let currentView = 'grid';
|
||||||
|
let currentImage = null;
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadImages();
|
||||||
|
setupDragAndDrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Настройка Drag & Drop
|
||||||
|
function setupDragAndDrop() {
|
||||||
|
const uploadArea = document.getElementById('uploadArea');
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragover', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragleave', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('drop', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
handleFileUpload(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка изображений
|
||||||
|
async function loadImages() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/gallery');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
currentImages = data.images || [];
|
||||||
|
filteredImages = [...currentImages];
|
||||||
|
renderImages();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.error || 'Ошибка загрузки изображений');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки изображений:', error);
|
||||||
|
showNotification('Ошибка загрузки изображений', 'error');
|
||||||
|
currentImages = [];
|
||||||
|
filteredImages = [];
|
||||||
|
renderEmptyState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение изображений
|
||||||
|
function renderImages() {
|
||||||
|
const grid = document.getElementById('imagesGrid');
|
||||||
|
const list = document.getElementById('imagesList');
|
||||||
|
|
||||||
|
if (filteredImages.length === 0) {
|
||||||
|
renderEmptyState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gridHTML = filteredImages.map(image => createImageCard(image)).join('');
|
||||||
|
grid.innerHTML = gridHTML;
|
||||||
|
|
||||||
|
// Список пока не реализован
|
||||||
|
list.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создание карточки изображения
|
||||||
|
function createImageCard(image) {
|
||||||
|
const fileName = image.path.split('/').pop();
|
||||||
|
const fileExtension = fileName.split('.').pop().toUpperCase();
|
||||||
|
const fileSize = image.size ? formatFileSize(image.size) : 'Неизвестно';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="image-card" onclick="viewImage('${image.path}', '${fileName}', '${fileSize}')">
|
||||||
|
<div class="image-preview" style="background-image: url('${image.path}')">
|
||||||
|
<div class="image-overlay">
|
||||||
|
<button class="overlay-btn" onclick="event.stopPropagation(); viewImage('${image.path}', '${fileName}', '${fileSize}')" title="Просмотр">👁️</button>
|
||||||
|
<button class="overlay-btn" onclick="event.stopPropagation(); copyToClipboard('${image.path}')" title="Копировать URL">📋</button>
|
||||||
|
<button class="overlay-btn" onclick="event.stopPropagation(); deleteImage('${image.path}')" title="Удалить">🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="image-info">
|
||||||
|
<div class="image-name">${fileName}</div>
|
||||||
|
<div class="image-details">
|
||||||
|
<span>${fileExtension}</span>
|
||||||
|
<span>${fileSize}</span>
|
||||||
|
</div>
|
||||||
|
<div class="image-url">${image.path}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пустое состояние
|
||||||
|
function renderEmptyState() {
|
||||||
|
const grid = document.getElementById('imagesGrid');
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; color: #6c757d;">
|
||||||
|
<div style="font-size: 64px; margin-bottom: 20px;">📷</div>
|
||||||
|
<h3>Изображения не найдены</h3>
|
||||||
|
<p>Загрузите первые изображения или проверьте фильтры поиска</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие диалога загрузки
|
||||||
|
function openUpload() {
|
||||||
|
document.getElementById('fileInput').click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка загрузки файлов
|
||||||
|
async function handleFileUpload(files) {
|
||||||
|
if (!files.length) return;
|
||||||
|
|
||||||
|
const uploadProgress = document.getElementById('uploadProgress');
|
||||||
|
const progressFill = document.getElementById('progressFill');
|
||||||
|
const progressText = document.getElementById('progressText');
|
||||||
|
|
||||||
|
uploadProgress.style.display = 'block';
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const file = files[i];
|
||||||
|
|
||||||
|
// Проверка типа файла
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
showNotification(\`Файл \${file.name} не является изображением\`, 'error');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка размера файла (10MB)
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
showNotification(\`Файл \${file.name} слишком большой (максимум 10MB)\`, 'error');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const progress = ((i + 1) / files.length) * 100;
|
||||||
|
progressFill.style.width = progress + '%';
|
||||||
|
progressText.textContent = \`Загрузка \${i + 1} из \${files.length}: \${file.name}\`;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
const response = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showNotification(\`Изображение \${file.name} загружено успешно\`, 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Ошибка загрузки');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки:', error);
|
||||||
|
showNotification(\`Ошибка загрузки \${file.name}\`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadProgress.style.display = 'none';
|
||||||
|
await loadImages(); // Перезагружаем галерею
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск изображений
|
||||||
|
function searchImages(query) {
|
||||||
|
if (!query.trim()) {
|
||||||
|
filteredImages = [...currentImages];
|
||||||
|
} else {
|
||||||
|
const searchTerm = query.toLowerCase();
|
||||||
|
filteredImages = currentImages.filter(image => {
|
||||||
|
const fileName = image.path.toLowerCase();
|
||||||
|
return fileName.includes(searchTerm);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтрация по типу
|
||||||
|
function filterByType(type) {
|
||||||
|
if (!type) {
|
||||||
|
filteredImages = [...currentImages];
|
||||||
|
} else {
|
||||||
|
filteredImages = currentImages.filter(image => {
|
||||||
|
return image.path.includes(\`/uploads/\${type}/\`) ||
|
||||||
|
image.path.includes(\`/\${type}/\`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Переключение вида
|
||||||
|
function setView(view) {
|
||||||
|
currentView = view;
|
||||||
|
|
||||||
|
// Обновляем кнопки
|
||||||
|
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.querySelector(\`[data-view="\${view}"]\`).classList.add('active');
|
||||||
|
|
||||||
|
// Переключаем отображение
|
||||||
|
const grid = document.getElementById('imagesGrid');
|
||||||
|
const list = document.getElementById('imagesList');
|
||||||
|
|
||||||
|
if (view === 'grid') {
|
||||||
|
grid.style.display = 'grid';
|
||||||
|
list.style.display = 'none';
|
||||||
|
} else {
|
||||||
|
grid.style.display = 'none';
|
||||||
|
list.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Просмотр изображения
|
||||||
|
function viewImage(path, name, size) {
|
||||||
|
currentImage = { path, name, size };
|
||||||
|
|
||||||
|
document.getElementById('previewImage').src = path;
|
||||||
|
document.getElementById('fileName').textContent = name;
|
||||||
|
document.getElementById('fileSize').textContent = size;
|
||||||
|
document.getElementById('fileUrl').textContent = path;
|
||||||
|
|
||||||
|
showModal('viewModal');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Копирование URL в буфер обмена
|
||||||
|
function copyToClipboard(url = null) {
|
||||||
|
const textToCopy = url || currentImage?.path || document.getElementById('fileUrl').textContent;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(textToCopy).then(() => {
|
||||||
|
showNotification('URL скопирован в буфер обмена', 'success');
|
||||||
|
}).catch(() => {
|
||||||
|
// Fallback для старых браузеров
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = textToCopy;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
showNotification('URL скопирован в буфер обмена', 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скачивание изображения
|
||||||
|
function downloadImage() {
|
||||||
|
if (currentImage) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = currentImage.path;
|
||||||
|
link.download = currentImage.name;
|
||||||
|
link.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаление изображения
|
||||||
|
async function deleteImage(path) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить это изображение?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/delete', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ path })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showNotification('Изображение удалено', 'success');
|
||||||
|
await loadImages();
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Ошибка удаления');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка удаления:', error);
|
||||||
|
showNotification('Ошибка удаления изображения', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление галереи
|
||||||
|
async function refreshGallery() {
|
||||||
|
showNotification('Обновление галереи...', 'info');
|
||||||
|
await loadImages();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показ модального окна
|
||||||
|
function showModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрытие модального окна
|
||||||
|
function closeModal(modalId) {
|
||||||
|
document.getElementById(modalId).classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показ уведомлений
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.className = \`notification \${type} show\`;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форматирование размера файла
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрытие модального окна по клику на overlay
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (e.target.classList.contains('modal')) {
|
||||||
|
e.target.classList.remove('show');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Горячие клавиши
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
document.querySelectorAll('.modal.show').forEach(modal => {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
BIN
public/images/logo_dark.png
Normal file
BIN
public/images/logo_dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
public/images/logo_white.png
Normal file
BIN
public/images/logo_white.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -230,19 +230,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Проверяем по имени поля или тексту label
|
// Проверяем по имени поля и содержанию
|
||||||
return fieldName.includes('image') ||
|
const isImageByName = fieldName.includes('image') && !fieldName.includes('title') && !fieldName.includes('alt');
|
||||||
fieldName.includes('photo') ||
|
const isImageByLabel = labelText.includes('image') || labelText.includes('изображение') || labelText.includes('фото');
|
||||||
fieldName.includes('avatar') ||
|
const isImageUrlField = fieldName.includes('image_url') || fieldName === 'image_url';
|
||||||
fieldName.includes('picture') ||
|
|
||||||
fieldName.includes('banner') ||
|
console.log(`🔍 Проверка поля "${fieldName}": isImageByName=${isImageByName}, isImageByLabel=${isImageByLabel}, isImageUrlField=${isImageUrlField}`);
|
||||||
fieldName.includes('thumbnail') ||
|
|
||||||
(fieldName.includes('url') && (labelText.includes('image') || labelText.includes('изображение'))) ||
|
return isImageUrlField || isImageByName || isImageByLabel;
|
||||||
labelText.includes('изображение') ||
|
|
||||||
labelText.includes('картинка') ||
|
|
||||||
labelText.includes('фото') ||
|
|
||||||
labelText.includes('image') ||
|
|
||||||
labelText.includes('picture');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Функция сканирования и добавления кнопок к полям изображений
|
// Функция сканирования и добавления кнопок к полям изображений
|
||||||
|
|||||||
@@ -32,7 +32,101 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// Поиск по сайту
|
// Инициализация компонентов бронирования
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Компонент для проверки доступности на главной странице
|
||||||
|
const availabilityContainer = document.getElementById('availability-checker-container');
|
||||||
|
const guideSelectorContainer = document.getElementById('guide-selector-container');
|
||||||
|
|
||||||
|
if (availabilityContainer) {
|
||||||
|
const availabilityChecker = new AvailabilityChecker({
|
||||||
|
container: availabilityContainer,
|
||||||
|
mode: 'detailed',
|
||||||
|
showSuggestions: true,
|
||||||
|
onAvailabilityCheck: function(result) {
|
||||||
|
if (result.availableGuides && result.availableGuides.length > 0) {
|
||||||
|
// Показать селектор гидов если есть доступные
|
||||||
|
if (guideSelectorContainer) {
|
||||||
|
guideSelectorContainer.style.display = 'block';
|
||||||
|
|
||||||
|
const guideSelector = new GuideSelector({
|
||||||
|
container: guideSelectorContainer,
|
||||||
|
mode: 'booking',
|
||||||
|
showAvailability: true,
|
||||||
|
selectedDate: result.date,
|
||||||
|
onGuideSelect: function(guide) {
|
||||||
|
// Перейти к бронированию с выбранным гидом
|
||||||
|
window.location.href = `/routes?guide=${guide.id}&date=${result.date}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (guideSelectorContainer) {
|
||||||
|
guideSelectorContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Календарь гидов на странице гидов
|
||||||
|
const guidesCalendarContainer = document.getElementById('guides-calendar-container');
|
||||||
|
if (guidesCalendarContainer) {
|
||||||
|
const guidesCalendar = new GuideCalendarWidget({
|
||||||
|
container: guidesCalendarContainer,
|
||||||
|
mode: 'readonly',
|
||||||
|
showControls: false,
|
||||||
|
showGuideInfo: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компоненты бронирования на странице маршрута
|
||||||
|
const bookingAvailabilityContainer = document.getElementById('booking-availability-checker');
|
||||||
|
const bookingGuideSelectorContainer = document.getElementById('booking-guide-selector');
|
||||||
|
|
||||||
|
if (bookingAvailabilityContainer) {
|
||||||
|
const bookingAvailabilityChecker = new AvailabilityChecker({
|
||||||
|
container: bookingAvailabilityContainer,
|
||||||
|
mode: 'inline',
|
||||||
|
showSuggestions: false,
|
||||||
|
onAvailabilityCheck: function(result) {
|
||||||
|
if (result.availableGuides && result.availableGuides.length > 0) {
|
||||||
|
if (bookingGuideSelectorContainer) {
|
||||||
|
bookingGuideSelectorContainer.style.display = 'block';
|
||||||
|
|
||||||
|
const bookingGuideSelector = new GuideSelector({
|
||||||
|
container: bookingGuideSelectorContainer,
|
||||||
|
mode: 'booking',
|
||||||
|
showAvailability: false,
|
||||||
|
availableGuides: result.availableGuides,
|
||||||
|
onGuideSelect: function(guide) {
|
||||||
|
// Заполнить скрытое поле с ID гида
|
||||||
|
const selectedGuideIdInput = document.getElementById('selectedGuideId');
|
||||||
|
const preferredDateInput = document.getElementById('preferred_date');
|
||||||
|
const submitBtn = document.getElementById('submitBookingBtn');
|
||||||
|
|
||||||
|
if (selectedGuideIdInput) {
|
||||||
|
selectedGuideIdInput.value = guide.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferredDateInput) {
|
||||||
|
preferredDateInput.value = result.date;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (submitBtn) {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Поиск по сайту (обновленная версия)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
const searchInput = document.getElementById('search-input');
|
const searchInput = document.getElementById('search-input');
|
||||||
const searchResults = document.getElementById('search-results');
|
const searchResults = document.getElementById('search-results');
|
||||||
@@ -378,6 +472,101 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Вспомогательные функции для компонентов
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Очистка результатов поиска
|
||||||
|
function clearSearchResults() {
|
||||||
|
const resultsContainer = document.getElementById('searchResults');
|
||||||
|
if (resultsContainer) {
|
||||||
|
resultsContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
const guideSelectorContainer = document.getElementById('guide-selector-container');
|
||||||
|
if (guideSelectorContainer) {
|
||||||
|
guideSelectorContainer.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для быстрого бронирования (вызывается из компонентов)
|
||||||
|
function quickBookTour(routeId, guideId, date, peopleCount = 1) {
|
||||||
|
// Создаем модальное окно для быстрого бронирования
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'modal fade';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Бронирование тура</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="quickBookingForm" action="/bookings" method="POST">
|
||||||
|
<input type="hidden" name="route_id" value="${routeId}">
|
||||||
|
<input type="hidden" name="guide_id" value="${guideId}">
|
||||||
|
<input type="hidden" name="preferred_date" value="${date}">
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Ваше имя *</label>
|
||||||
|
<input type="text" class="form-control" name="customer_name" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Количество человек</label>
|
||||||
|
<input type="number" class="form-control" name="people_count" value="${peopleCount}" min="1" max="20" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Email *</label>
|
||||||
|
<input type="email" class="form-control" name="customer_email" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label">Телефон *</label>
|
||||||
|
<input type="tel" class="form-control" name="customer_phone" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Особые пожелания</label>
|
||||||
|
<textarea class="form-control" name="special_requirements" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Отмена</button>
|
||||||
|
<button type="submit" form="quickBookingForm" class="btn btn-primary">
|
||||||
|
<i class="fas fa-credit-card me-1"></i>Забронировать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
const bootstrapModal = new bootstrap.Modal(modal);
|
||||||
|
bootstrapModal.show();
|
||||||
|
|
||||||
|
// Удаление модального окна после закрытия
|
||||||
|
modal.addEventListener('hidden.bs.modal', function() {
|
||||||
|
document.body.removeChild(modal);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Делаем функции доступными глобально для использования в компонентах
|
||||||
|
window.clearSearchResults = clearSearchResults;
|
||||||
|
window.quickBookTour = quickBookTour;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Утилитарные функции (продолжение)
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Финальные утилитарные функции
|
||||||
|
// ==========================================
|
||||||
function createAlertContainer() {
|
function createAlertContainer() {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
container.id = 'alert-container';
|
container.id = 'alert-container';
|
||||||
@@ -387,5 +576,13 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
return container;
|
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! 🇰🇷');
|
||||||
});
|
});
|
||||||
477
public/js/universal-media-manager-integration.js
Normal file
477
public/js/universal-media-manager-integration.js
Normal file
@@ -0,0 +1,477 @@
|
|||||||
|
/**
|
||||||
|
* Универсальная интеграция медиа-менеджера в AdminJS
|
||||||
|
* Заменяет все стандартные диалоги выбора файлов на медиа-менеджер
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
console.log('🚀 Загружается универсальный медиа-менеджер для AdminJS...');
|
||||||
|
|
||||||
|
let mediaManagerModal = null;
|
||||||
|
let currentCallback = null;
|
||||||
|
|
||||||
|
// Создание модального окна медиа-менеджера
|
||||||
|
function createMediaManagerModal() {
|
||||||
|
if (mediaManagerModal) return mediaManagerModal;
|
||||||
|
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'universal-media-modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="universal-media-overlay"></div>
|
||||||
|
<div class="universal-media-content">
|
||||||
|
<div class="universal-media-header">
|
||||||
|
<h3>📁 Выбор изображения</h3>
|
||||||
|
<button class="universal-media-close">×</button>
|
||||||
|
</div>
|
||||||
|
<iframe class="universal-media-frame" src="/universal-media-manager.html"></iframe>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// CSS стили
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = `
|
||||||
|
.universal-media-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-media-modal.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-media-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-media-content {
|
||||||
|
position: relative;
|
||||||
|
width: 90vw;
|
||||||
|
height: 90vh;
|
||||||
|
max-width: 1200px;
|
||||||
|
max-height: 800px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-media-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 15px 20px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-media-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-media-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-media-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.universal-media-frame {
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 60px);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для кнопок медиа-менеджера */
|
||||||
|
.media-manager-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-manager-btn:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-manager-btn.small {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Скрываем стандартные input[type="file"] */
|
||||||
|
.media-replaced input[type="file"] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для preview изображений */
|
||||||
|
.media-preview {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 150px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 10px 0;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview.selected {
|
||||||
|
border-color: #28a745;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (!document.querySelector('#universal-media-styles')) {
|
||||||
|
style.id = 'universal-media-styles';
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
// События
|
||||||
|
const closeBtn = modal.querySelector('.universal-media-close');
|
||||||
|
const overlay = modal.querySelector('.universal-media-overlay');
|
||||||
|
|
||||||
|
closeBtn.addEventListener('click', closeMediaManager);
|
||||||
|
overlay.addEventListener('click', closeMediaManager);
|
||||||
|
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
mediaManagerModal = modal;
|
||||||
|
|
||||||
|
return modal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открытие медиа-менеджера
|
||||||
|
function openMediaManager(callback, options = {}) {
|
||||||
|
const modal = createMediaManagerModal();
|
||||||
|
currentCallback = callback;
|
||||||
|
|
||||||
|
// Обновляем заголовок если нужно
|
||||||
|
const header = modal.querySelector('.universal-media-header h3');
|
||||||
|
header.textContent = options.title || '📁 Выбор изображения';
|
||||||
|
|
||||||
|
modal.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрытие медиа-менеджера
|
||||||
|
function closeMediaManager() {
|
||||||
|
if (mediaManagerModal) {
|
||||||
|
mediaManagerModal.classList.remove('active');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
currentCallback = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка сообщений от медиа-менеджера
|
||||||
|
window.addEventListener('message', function(event) {
|
||||||
|
if (event.data.type === 'media-manager-selection' && currentCallback) {
|
||||||
|
const files = event.data.files;
|
||||||
|
if (files && files.length > 0) {
|
||||||
|
currentCallback(files);
|
||||||
|
closeMediaManager();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Замена стандартных input[type="file"] на медиа-менеджер
|
||||||
|
function replaceFileInputs() {
|
||||||
|
const fileInputs = document.querySelectorAll('input[type="file"]:not(.media-replaced)');
|
||||||
|
|
||||||
|
fileInputs.forEach(input => {
|
||||||
|
if (input.accept && !input.accept.includes('image')) {
|
||||||
|
return; // Пропускаем не-изображения
|
||||||
|
}
|
||||||
|
|
||||||
|
input.classList.add('media-replaced');
|
||||||
|
|
||||||
|
// Создаем кнопку медиа-менеджера
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'media-manager-btn';
|
||||||
|
button.innerHTML = '📷 Выбрать изображение';
|
||||||
|
|
||||||
|
// Добавляем preview
|
||||||
|
const preview = document.createElement('img');
|
||||||
|
preview.className = 'media-preview';
|
||||||
|
preview.style.display = 'none';
|
||||||
|
|
||||||
|
// Добавляем скрытый input для хранения пути
|
||||||
|
const hiddenInput = document.createElement('input');
|
||||||
|
hiddenInput.type = 'hidden';
|
||||||
|
hiddenInput.name = input.name;
|
||||||
|
hiddenInput.value = input.value || '';
|
||||||
|
|
||||||
|
// Событие клика
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
openMediaManager((files) => {
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
// Обновляем значения
|
||||||
|
hiddenInput.value = file.url;
|
||||||
|
input.value = file.url;
|
||||||
|
|
||||||
|
// Показываем preview
|
||||||
|
preview.src = file.url;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
preview.alt = file.name;
|
||||||
|
|
||||||
|
// Обновляем кнопку
|
||||||
|
button.innerHTML = '✏️ Заменить изображение';
|
||||||
|
|
||||||
|
// Добавляем кнопку удаления
|
||||||
|
if (!button.nextElementSibling?.classList.contains('media-remove-btn')) {
|
||||||
|
const removeBtn = document.createElement('button');
|
||||||
|
removeBtn.type = 'button';
|
||||||
|
removeBtn.className = 'media-manager-btn small';
|
||||||
|
removeBtn.style.background = '#dc3545';
|
||||||
|
removeBtn.innerHTML = '🗑️ Удалить';
|
||||||
|
|
||||||
|
removeBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
// Очищаем значения
|
||||||
|
hiddenInput.value = '';
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
// Скрываем preview
|
||||||
|
preview.style.display = 'none';
|
||||||
|
|
||||||
|
// Восстанавливаем кнопку
|
||||||
|
button.innerHTML = '📷 Выбрать изображение';
|
||||||
|
removeBtn.remove();
|
||||||
|
});
|
||||||
|
|
||||||
|
button.parentElement.insertBefore(removeBtn, button.nextSibling);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вызываем событие change для совместимости
|
||||||
|
const changeEvent = new Event('change', { bubbles: true });
|
||||||
|
input.dispatchEvent(changeEvent);
|
||||||
|
}, {
|
||||||
|
title: input.dataset.title || 'Выбор изображения'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вставляем элементы
|
||||||
|
input.parentElement.insertBefore(button, input.nextSibling);
|
||||||
|
input.parentElement.insertBefore(preview, button.nextSibling);
|
||||||
|
input.parentElement.insertBefore(hiddenInput, preview.nextSibling);
|
||||||
|
|
||||||
|
// Если есть начальное значение, показываем preview
|
||||||
|
if (input.value) {
|
||||||
|
preview.src = input.value;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
button.innerHTML = '✏️ Заменить изображение';
|
||||||
|
hiddenInput.value = input.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Замена кнопок "Browse" в формах AdminJS
|
||||||
|
function replaceAdminJSBrowseButtons() {
|
||||||
|
// Ищем кнопки загрузки файлов AdminJS
|
||||||
|
const browseButtons = document.querySelectorAll('button[type="button"]:not(.media-replaced)');
|
||||||
|
|
||||||
|
browseButtons.forEach(button => {
|
||||||
|
const buttonText = button.textContent.toLowerCase();
|
||||||
|
|
||||||
|
if (buttonText.includes('browse') ||
|
||||||
|
buttonText.includes('выбрать') ||
|
||||||
|
buttonText.includes('загрузить') ||
|
||||||
|
buttonText.includes('upload')) {
|
||||||
|
|
||||||
|
button.classList.add('media-replaced');
|
||||||
|
|
||||||
|
// Заменяем обработчик клика
|
||||||
|
const originalHandler = button.onclick;
|
||||||
|
button.onclick = null;
|
||||||
|
|
||||||
|
button.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
openMediaManager((files) => {
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
// Ищем соответствующий input
|
||||||
|
const container = button.closest('.form-group, .field, .input-group');
|
||||||
|
const input = container?.querySelector('input[type="text"], input[type="url"], input[type="hidden"]');
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
input.value = file.url;
|
||||||
|
|
||||||
|
// Вызываем событие change
|
||||||
|
const changeEvent = new Event('change', { bubbles: true });
|
||||||
|
input.dispatchEvent(changeEvent);
|
||||||
|
|
||||||
|
// Обновляем preview если есть
|
||||||
|
const preview = container.querySelector('img');
|
||||||
|
if (preview) {
|
||||||
|
preview.src = file.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обновляем текст кнопки
|
||||||
|
button.innerHTML = '📷 Медиа-менеджер';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интеграция с полями изображений AdminJS
|
||||||
|
function integrateWithAdminJSImageFields() {
|
||||||
|
// Ищем поля с атрибутом accept="image/*"
|
||||||
|
const imageFields = document.querySelectorAll('input[accept*="image"]:not(.media-replaced)');
|
||||||
|
|
||||||
|
imageFields.forEach(field => {
|
||||||
|
field.classList.add('media-replaced');
|
||||||
|
|
||||||
|
const container = field.closest('.form-group, .field');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
// Создаем кнопку медиа-менеджера
|
||||||
|
const mediaBtn = document.createElement('button');
|
||||||
|
mediaBtn.type = 'button';
|
||||||
|
mediaBtn.className = 'media-manager-btn';
|
||||||
|
mediaBtn.innerHTML = '📷 Открыть медиа-менеджер';
|
||||||
|
|
||||||
|
mediaBtn.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
openMediaManager((files) => {
|
||||||
|
const file = files[0];
|
||||||
|
|
||||||
|
// Обновляем поле
|
||||||
|
field.value = file.url;
|
||||||
|
|
||||||
|
// Создаем событие change
|
||||||
|
const event = new Event('change', { bubbles: true });
|
||||||
|
field.dispatchEvent(event);
|
||||||
|
|
||||||
|
// Если есть label, обновляем его
|
||||||
|
const label = container.querySelector('label');
|
||||||
|
if (label && !label.querySelector('.selected-file')) {
|
||||||
|
const selectedSpan = document.createElement('span');
|
||||||
|
selectedSpan.className = 'selected-file';
|
||||||
|
selectedSpan.style.cssText = 'color: #28a745; font-weight: 500; margin-left: 10px;';
|
||||||
|
selectedSpan.textContent = `✓ ${file.name}`;
|
||||||
|
label.appendChild(selectedSpan);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Вставляем кнопку после поля
|
||||||
|
field.parentElement.insertBefore(mediaBtn, field.nextSibling);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Основная функция инициализации
|
||||||
|
function initMediaManager() {
|
||||||
|
console.log('🔧 Инициализация медиа-менеджера...');
|
||||||
|
|
||||||
|
// Замена различных типов полей
|
||||||
|
replaceFileInputs();
|
||||||
|
replaceAdminJSBrowseButtons();
|
||||||
|
integrateWithAdminJSImageFields();
|
||||||
|
|
||||||
|
console.log('✅ Медиа-менеджер инициализирован');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Наблюдатель за изменениями DOM
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
let shouldReinit = false;
|
||||||
|
|
||||||
|
mutations.forEach((mutation) => {
|
||||||
|
if (mutation.type === 'childList') {
|
||||||
|
mutation.addedNodes.forEach((node) => {
|
||||||
|
if (node.nodeType === 1) { // Element node
|
||||||
|
if (node.querySelector && (
|
||||||
|
node.querySelector('input[type="file"]') ||
|
||||||
|
node.querySelector('input[accept*="image"]') ||
|
||||||
|
node.querySelector('button[type="button"]')
|
||||||
|
)) {
|
||||||
|
shouldReinit = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldReinit) {
|
||||||
|
setTimeout(initMediaManager, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Запуск
|
||||||
|
function start() {
|
||||||
|
// Ждем загрузки DOM
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initMediaManager);
|
||||||
|
} else {
|
||||||
|
initMediaManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск наблюдателя
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Переинициализация при изменениях в SPA
|
||||||
|
let lastUrl = location.href;
|
||||||
|
setInterval(() => {
|
||||||
|
if (location.href !== lastUrl) {
|
||||||
|
lastUrl = location.href;
|
||||||
|
setTimeout(initMediaManager, 500);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Глобальная функция для ручного открытия медиа-менеджера
|
||||||
|
window.openUniversalMediaManager = function(callback, options) {
|
||||||
|
openMediaManager(callback, options);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Запуск
|
||||||
|
start();
|
||||||
|
|
||||||
|
})();
|
||||||
1010
public/professional-style-editor.html
Normal file
1010
public/professional-style-editor.html
Normal file
File diff suppressed because it is too large
Load Diff
282
public/schedule-manager.html
Normal file
282
public/schedule-manager.html
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Планировщик рабочих смен - Корея Тур Агентство</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-border {
|
||||||
|
width: 4rem;
|
||||||
|
height: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивность */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-section {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимации */
|
||||||
|
.main-container {
|
||||||
|
animation: slideUp 0.6s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
transform: translateY(30px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn 0.8s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="main-container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="header-section position-relative">
|
||||||
|
<a href="/admin" class="back-button">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>Назад в админку
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="header-title">
|
||||||
|
<i class="fas fa-calendar-week me-3"></i>
|
||||||
|
Планировщик рабочих смен
|
||||||
|
</div>
|
||||||
|
<div class="header-subtitle">
|
||||||
|
Управление расписанием работы гидов
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="content-section">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="loading" class="loading-spinner">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<h5>Загрузка планировщика смен...</h5>
|
||||||
|
<p class="text-muted">Подождите, пожалуйста</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div id="error" class="error-message" style="display: none;">
|
||||||
|
<div class="error-icon">
|
||||||
|
<i class="fas fa-exclamation-triangle"></i>
|
||||||
|
</div>
|
||||||
|
<h4>Ошибка загрузки</h4>
|
||||||
|
<p>Не удалось загрузить планировщик смен. Попробуйте перезагрузить страницу.</p>
|
||||||
|
<button class="btn btn-primary" onclick="location.reload()">
|
||||||
|
<i class="fas fa-redo me-2"></i>Перезагрузить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedule Manager Container -->
|
||||||
|
<div id="scheduleManagerContainer" class="fade-in" style="display: none;">
|
||||||
|
<!-- Компонент будет загружен здесь -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bootstrap JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<!-- Schedule Manager Component -->
|
||||||
|
<script src="/components/guide-schedule-manager.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
let retryCount = 0;
|
||||||
|
const maxRetries = 3;
|
||||||
|
|
||||||
|
async function initializeScheduleManager() {
|
||||||
|
try {
|
||||||
|
// Проверяем доступность компонента
|
||||||
|
if (typeof GuideScheduleManager === 'undefined') {
|
||||||
|
throw new Error('GuideScheduleManager component not loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализируем планировщик смен
|
||||||
|
const scheduleManager = new GuideScheduleManager({
|
||||||
|
container: document.getElementById('scheduleManagerContainer'),
|
||||||
|
onScheduleChange: function(scheduleData) {
|
||||||
|
console.log('Расписание изменено:', scheduleData);
|
||||||
|
|
||||||
|
// Можно добавить уведомления об успешном сохранении
|
||||||
|
showNotification('Расписание успешно сохранено!', 'success');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Показываем контейнер и скрываем загрузку
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
document.getElementById('scheduleManagerContainer').style.display = 'block';
|
||||||
|
|
||||||
|
console.log('Планировщик смен инициализирован успешно');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка инициализации планировщика смен:', error);
|
||||||
|
|
||||||
|
if (retryCount < maxRetries) {
|
||||||
|
retryCount++;
|
||||||
|
console.log(`Попытка повторной загрузки ${retryCount}/${maxRetries}`);
|
||||||
|
setTimeout(initializeScheduleManager, 2000);
|
||||||
|
} else {
|
||||||
|
// Показываем ошибку
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
document.getElementById('error').style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для показа уведомлений
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
// Создаем контейнер для уведомлений если его нет
|
||||||
|
let container = document.getElementById('notification-container');
|
||||||
|
if (!container) {
|
||||||
|
container = document.createElement('div');
|
||||||
|
container.id = 'notification-container';
|
||||||
|
container.style.cssText = 'position: fixed; top: 20px; right: 20px; z-index: 9999;';
|
||||||
|
document.body.appendChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем уведомление
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `alert alert-${type} alert-dismissible fade show`;
|
||||||
|
notification.style.cssText = 'min-width: 300px; margin-bottom: 10px;';
|
||||||
|
notification.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.appendChild(notification);
|
||||||
|
|
||||||
|
// Автоматически удаляем через 5 секунд
|
||||||
|
setTimeout(() => {
|
||||||
|
if (notification.parentNode) {
|
||||||
|
notification.remove();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Глобальная функция для уведомлений
|
||||||
|
window.showNotification = showNotification;
|
||||||
|
|
||||||
|
// Запускаем инициализацию после небольшой задержки
|
||||||
|
setTimeout(initializeScheduleManager, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработка ошибок загрузки скриптов
|
||||||
|
window.addEventListener('error', function(e) {
|
||||||
|
if (e.filename && e.filename.includes('guide-schedule-manager.js')) {
|
||||||
|
console.error('Ошибка загрузки компонента планировщика смен:', e);
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
document.getElementById('error').style.display = 'block';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1013
public/style-editor-advanced.html
Normal file
1013
public/style-editor-advanced.html
Normal file
File diff suppressed because it is too large
Load Diff
143
public/test-image-editor-debug.html
Normal file
143
public/test-image-editor-debug.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Тест редактора изображений</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
.test-form {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
input[type="text"] {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.test-results {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.adminjs-app { /* Имитируем класс AdminJS */ }
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.status.success { background: #d4edda; color: #155724; }
|
||||||
|
.status.error { background: #f8d7da; color: #721c24; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="adminjs-app">
|
||||||
|
<h1>🧪 Тест редактора изображений</h1>
|
||||||
|
<p>Эта страница тестирует интеграцию редактора изображений с AdminJS</p>
|
||||||
|
|
||||||
|
<form class="test-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="image_url">URL изображения маршрута:</label>
|
||||||
|
<input type="text" name="image_url" id="image_url" value="/uploads/routes/example.jpg">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="profile_image">Изображение профиля:</label>
|
||||||
|
<input type="text" name="profile_image" id="profile_image" value="">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="article_image_url">Изображение статьи:</label>
|
||||||
|
<input type="text" name="article_image_url" id="article_image_url" value="">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="title">Обычное поле (заголовок):</label>
|
||||||
|
<input type="text" name="title" id="title" value="Тест обычного поля">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="description">Описание:</label>
|
||||||
|
<input type="text" name="description" id="description" value="Это поле не должно иметь кнопку редактора">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="test-results">
|
||||||
|
<h3>📊 Результаты теста:</h3>
|
||||||
|
<div id="test-output">
|
||||||
|
<p>⏳ Загрузка и инициализация скрипта...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Имитируем AdminJS окружение
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
console.log('🧪 Страница теста загружена');
|
||||||
|
|
||||||
|
// Загружаем наш скрипт
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = '/js/admin-image-selector-fixed.js';
|
||||||
|
script.onload = function() {
|
||||||
|
console.log('✅ Скрипт admin-image-selector-fixed.js загружен');
|
||||||
|
updateTestResults();
|
||||||
|
};
|
||||||
|
script.onerror = function() {
|
||||||
|
console.error('❌ Ошибка загрузки скрипта admin-image-selector-fixed.js');
|
||||||
|
updateTestResults();
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
|
||||||
|
// Обновляем результаты тестирования через некоторое время
|
||||||
|
setTimeout(updateTestResults, 2000);
|
||||||
|
setTimeout(updateTestResults, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateTestResults() {
|
||||||
|
const outputDiv = document.getElementById('test-output');
|
||||||
|
const imageFields = document.querySelectorAll('input[name*="image"]');
|
||||||
|
const regularFields = document.querySelectorAll('input:not([name*="image"])');
|
||||||
|
|
||||||
|
let html = '<h4>🔍 Анализ полей:</h4>';
|
||||||
|
|
||||||
|
// Проверяем поля изображений
|
||||||
|
html += '<h5>Поля изображений:</h5>';
|
||||||
|
imageFields.forEach(field => {
|
||||||
|
const hasButton = field.parentNode.querySelector('.image-editor-btn');
|
||||||
|
const status = hasButton ? 'success' : 'error';
|
||||||
|
const statusText = hasButton ? '✅ Кнопка добавлена' : '❌ Кнопка отсутствует';
|
||||||
|
html += `<p><strong>${field.name}:</strong> <span class="status ${status}">${statusText}</span></p>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем обычные поля
|
||||||
|
html += '<h5>Обычные поля:</h5>';
|
||||||
|
regularFields.forEach(field => {
|
||||||
|
const hasButton = field.parentNode.querySelector('.image-editor-btn');
|
||||||
|
const status = hasButton ? 'error' : 'success';
|
||||||
|
const statusText = hasButton ? '❌ Кнопка добавлена (ошибка)' : '✅ Кнопка отсутствует';
|
||||||
|
html += `<p><strong>${field.name}:</strong> <span class="status ${status}">${statusText}</span></p>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
outputDiv.innerHTML = html;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
625
public/tours-calendar.html
Normal file
625
public/tours-calendar.html
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Календарь туров - Korea Tourism</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 30px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 30px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.month-title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
color: #333;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 1px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.week-day {
|
||||||
|
padding: 15px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #666;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.days-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
min-height: 120px;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #fafafa;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.empty {
|
||||||
|
background: transparent;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.empty:hover {
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.has-tours {
|
||||||
|
background: linear-gradient(135deg, #e8f5e8 0%, #f0f8f0 100%);
|
||||||
|
border-color: #4caf50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.has-tours:hover {
|
||||||
|
border-color: #2e7d32;
|
||||||
|
box-shadow: 0 8px 25px rgba(76, 175, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell.has-tours .day-number {
|
||||||
|
color: #2e7d32;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tours-count {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-types {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-type {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-city { background: #2196f3; }
|
||||||
|
.tour-mountain { background: #4caf50; }
|
||||||
|
.tour-fishing { background: #ff5722; }
|
||||||
|
|
||||||
|
.guides-info {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #888;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Модальное окно */
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 30px;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 25px 50px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
border-bottom: 2px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: #333;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f44336;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: #d32f2f;
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
border-left: 4px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-card:hover {
|
||||||
|
transform: translateX(5px);
|
||||||
|
box-shadow: 0 6px 15px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-card.city {
|
||||||
|
border-left-color: #2196f3;
|
||||||
|
background: linear-gradient(135deg, #e3f2fd 0%, #f8f9fa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-card.mountain {
|
||||||
|
border-left-color: #4caf50;
|
||||||
|
background: linear-gradient(135deg, #e8f5e8 0%, #f8f9fa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-card.fishing {
|
||||||
|
border-left-color: #ff5722;
|
||||||
|
background: linear-gradient(135deg, #ffebee 0%, #f8f9fa 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-detail {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tour-description {
|
||||||
|
color: #777;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-btn {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 10px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-tours {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.calendar-nav {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.days-grid {
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-cell {
|
||||||
|
min-height: 80px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="calendar-container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🗓️ Календарь туров</h1>
|
||||||
|
<p>Выберите дату и найдите доступные туры с лучшими гидами Кореи</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-nav">
|
||||||
|
<button class="nav-btn" onclick="changeMonth(-1)">← Предыдущий месяц</button>
|
||||||
|
<div class="month-title" id="currentMonth">Загрузка...</div>
|
||||||
|
<button class="nav-btn" onclick="changeMonth(1)">Следующий месяц →</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<div class="week-header">
|
||||||
|
<div class="week-day">ПН</div>
|
||||||
|
<div class="week-day">ВТ</div>
|
||||||
|
<div class="week-day">СР</div>
|
||||||
|
<div class="week-day">ЧТ</div>
|
||||||
|
<div class="week-day">ПТ</div>
|
||||||
|
<div class="week-day">СБ</div>
|
||||||
|
<div class="week-day">ВС</div>
|
||||||
|
</div>
|
||||||
|
<div class="days-grid" id="calendarDays">
|
||||||
|
<!-- Дни будут добавлены через JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color tour-city"></div>
|
||||||
|
<span>Городские туры</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color tour-mountain"></div>
|
||||||
|
<span>Горные походы</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color tour-fishing"></div>
|
||||||
|
<span>Морская рыбалка</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно -->
|
||||||
|
<div id="tourModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<div class="modal-title" id="modalTitle">Туры на выбранную дату</div>
|
||||||
|
<button class="close-btn" onclick="closeModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="modalContent">
|
||||||
|
<!-- Содержимое будет добавлено через JavaScript -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentDate = new Date();
|
||||||
|
let calendarData = {};
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||||
|
];
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
await loadCalendarData();
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCalendarData() {
|
||||||
|
try {
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth() + 1;
|
||||||
|
|
||||||
|
const response = await fetch(`/api/tours-calendar?year=${year}&month=${month}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
calendarData = {};
|
||||||
|
data.data.forEach(item => {
|
||||||
|
calendarData[item.work_date] = item;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки календарных данных:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
document.getElementById('currentMonth').textContent =
|
||||||
|
monthNames[currentDate.getMonth()] + ' ' + currentDate.getFullYear();
|
||||||
|
|
||||||
|
const calendarDays = document.getElementById('calendarDays');
|
||||||
|
calendarDays.innerHTML = '';
|
||||||
|
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const firstDay = new Date(year, month, 1).getDay();
|
||||||
|
const startDay = firstDay === 0 ? 6 : firstDay - 1;
|
||||||
|
|
||||||
|
// Пустые дни в начале
|
||||||
|
for (let i = 0; i < startDay; i++) {
|
||||||
|
const dayCell = document.createElement('div');
|
||||||
|
dayCell.className = 'day-cell empty';
|
||||||
|
calendarDays.appendChild(dayCell);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дни месяца
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const dayCell = document.createElement('div');
|
||||||
|
dayCell.className = 'day-cell';
|
||||||
|
|
||||||
|
const dateStr = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(day).padStart(2, '0');
|
||||||
|
const dayData = calendarData[dateStr];
|
||||||
|
|
||||||
|
const dayNumber = document.createElement('div');
|
||||||
|
dayNumber.className = 'day-number';
|
||||||
|
dayNumber.textContent = day;
|
||||||
|
dayCell.appendChild(dayNumber);
|
||||||
|
|
||||||
|
if (dayData) {
|
||||||
|
dayCell.classList.add('has-tours');
|
||||||
|
dayCell.onclick = () => openTourModal(dateStr);
|
||||||
|
|
||||||
|
const toursCount = document.createElement('div');
|
||||||
|
toursCount.className = 'tours-count';
|
||||||
|
toursCount.textContent = `${dayData.routes_count} туров`;
|
||||||
|
dayCell.appendChild(toursCount);
|
||||||
|
|
||||||
|
const tourTypes = document.createElement('div');
|
||||||
|
tourTypes.className = 'tour-types';
|
||||||
|
|
||||||
|
const specializations = new Set();
|
||||||
|
dayData.guides_data.forEach(guide => {
|
||||||
|
if (guide.specialization) {
|
||||||
|
specializations.add(guide.specialization);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
specializations.forEach(spec => {
|
||||||
|
const tourType = document.createElement('div');
|
||||||
|
tourType.className = `tour-type tour-${spec}`;
|
||||||
|
tourTypes.appendChild(tourType);
|
||||||
|
});
|
||||||
|
|
||||||
|
dayCell.appendChild(tourTypes);
|
||||||
|
|
||||||
|
const guidesInfo = document.createElement('div');
|
||||||
|
guidesInfo.className = 'guides-info';
|
||||||
|
guidesInfo.textContent = `${dayData.guides_count} гидов`;
|
||||||
|
dayCell.appendChild(guidesInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarDays.appendChild(dayCell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openTourModal(date) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tours-by-date?date=${date}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
alert('Ошибка загрузки туров');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('tourModal');
|
||||||
|
const modalTitle = document.getElementById('modalTitle');
|
||||||
|
const modalContent = document.getElementById('modalContent');
|
||||||
|
|
||||||
|
const dateObj = new Date(date);
|
||||||
|
const formattedDate = dateObj.toLocaleDateString('ru-RU', {
|
||||||
|
weekday: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
|
modalTitle.textContent = `Туры на ${formattedDate}`;
|
||||||
|
|
||||||
|
if (data.data.length === 0) {
|
||||||
|
modalContent.innerHTML = `
|
||||||
|
<div class="no-tours">
|
||||||
|
<h3>На эту дату туры недоступны</h3>
|
||||||
|
<p>Выберите другую дату или свяжитесь с нами для индивидуального тура</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
modalContent.innerHTML = data.data.map(tour => `
|
||||||
|
<div class="tour-card ${tour.type}">
|
||||||
|
<div class="tour-title">${tour.title}</div>
|
||||||
|
<div class="tour-details">
|
||||||
|
<div class="tour-detail">
|
||||||
|
👤 <strong>${tour.guide_name}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="tour-detail">
|
||||||
|
💰 ${tour.price ? tour.price + ' ₩' : 'По запросу'}
|
||||||
|
</div>
|
||||||
|
<div class="tour-detail">
|
||||||
|
⏰ ${tour.duration ? tour.duration + ' часов' : 'По договоренности'}
|
||||||
|
</div>
|
||||||
|
<div class="tour-detail">
|
||||||
|
🎯 ${getTypeLabel(tour.type)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${tour.description ? `<div class="tour-description">${tour.description.substring(0, 200)}...</div>` : ''}
|
||||||
|
${tour.guide_notes ? `<div style="font-style: italic; color: #666; margin-top: 8px;">Заметки гида: ${tour.guide_notes}</div>` : ''}
|
||||||
|
<button class="book-btn" onclick="bookTour(${tour.id}, '${date}')">
|
||||||
|
📞 Забронировать тур
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.style.display = 'block';
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки туров:', error);
|
||||||
|
alert('Ошибка загрузки туров');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('tourModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTypeLabel(type) {
|
||||||
|
const types = {
|
||||||
|
'city': 'Городские экскурсии',
|
||||||
|
'mountain': 'Горные походы',
|
||||||
|
'fishing': 'Морская рыбалка'
|
||||||
|
};
|
||||||
|
return types[type] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookTour(tourId, date) {
|
||||||
|
// Здесь можно добавить логику бронирования
|
||||||
|
alert(`Функция бронирования тура #${tourId} на ${date} будет добавлена позже`);
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeMonth(delta) {
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + delta);
|
||||||
|
await loadCalendarData();
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрытие модального окна при клике вне его
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('tourModal');
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
909
public/universal-media-manager.html
Normal file
909
public/universal-media-manager.html
Normal file
@@ -0,0 +1,909 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Универсальный медиа-менеджер</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background: #f8f9fa;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-manager {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.media-header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 15px 20px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.media-toolbar {
|
||||||
|
background: white;
|
||||||
|
padding: 15px 20px;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone {
|
||||||
|
position: relative;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone:hover {
|
||||||
|
background: #218838;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-zone input {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-box {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn.active {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Area */
|
||||||
|
.media-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.media-sidebar {
|
||||||
|
width: 250px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-right: 1px solid #e9ecef;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-tree {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-item.active {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gallery */
|
||||||
|
.media-gallery {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item {
|
||||||
|
background: white;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0,123,255,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item.selected {
|
||||||
|
border-color: #28a745;
|
||||||
|
background: #f8fff8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-item.selected::after {
|
||||||
|
content: '✓';
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-size {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* List View */
|
||||||
|
.list-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-preview {
|
||||||
|
width: 60px;
|
||||||
|
height: 45px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Actions */
|
||||||
|
.action-btn {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
background: white;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.delete {
|
||||||
|
color: #dc3545;
|
||||||
|
border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.delete:hover {
|
||||||
|
background: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drop Zone */
|
||||||
|
.drop-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0,123,255,0.8);
|
||||||
|
color: white;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status Bar */
|
||||||
|
.media-status {
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
display: flex;
|
||||||
|
justify-content: between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-left {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-right {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection Actions */
|
||||||
|
.selection-actions {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selection-actions.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn {
|
||||||
|
background: #28a745;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-btn:hover {
|
||||||
|
background: #218838;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.media-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 70vh;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
padding: 15px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.loading {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 4px solid #f3f3f3;
|
||||||
|
border-top: 4px solid #007bff;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.media-sidebar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="media-manager">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="media-header">
|
||||||
|
<div class="media-title">
|
||||||
|
<span>📁</span>
|
||||||
|
Медиа-менеджер
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toolbar -->
|
||||||
|
<div class="media-toolbar">
|
||||||
|
<div class="upload-zone">
|
||||||
|
<input type="file" id="fileInput" multiple accept="image/*">
|
||||||
|
<span>📤</span>
|
||||||
|
Загрузить файлы
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="text" class="search-box" id="searchBox" placeholder="🔍 Поиск файлов...">
|
||||||
|
|
||||||
|
<div class="view-toggle">
|
||||||
|
<button class="view-btn active" data-view="grid">⊞</button>
|
||||||
|
<button class="view-btn" data-view="list">☰</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selection Actions -->
|
||||||
|
<div class="selection-actions" id="selectionActions">
|
||||||
|
<span id="selectionCount">0 файлов выбрано</span>
|
||||||
|
<button class="select-btn" id="useSelectedBtn">Использовать выбранные</button>
|
||||||
|
<button class="action-btn delete" id="deleteSelectedBtn">Удалить выбранные</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="media-content">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="media-sidebar">
|
||||||
|
<ul class="folder-tree" id="folderTree">
|
||||||
|
<li class="folder-item active" data-folder="all">
|
||||||
|
<span>📁</span> Все файлы
|
||||||
|
</li>
|
||||||
|
<li class="folder-item" data-folder="routes">
|
||||||
|
<span>📁</span> Маршруты
|
||||||
|
</li>
|
||||||
|
<li class="folder-item" data-folder="guides">
|
||||||
|
<span>👥</span> Гиды
|
||||||
|
</li>
|
||||||
|
<li class="folder-item" data-folder="articles">
|
||||||
|
<span>📰</span> Статьи
|
||||||
|
</li>
|
||||||
|
<li class="folder-item" data-folder="general">
|
||||||
|
<span>🗂️</span> Общие
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gallery -->
|
||||||
|
<div class="media-gallery">
|
||||||
|
<div class="loading" id="loadingIndicator">
|
||||||
|
<div class="spinner"></div>
|
||||||
|
Загрузка файлов...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="gallery-grid" id="mediaGrid"></div>
|
||||||
|
|
||||||
|
<div class="empty-state" id="emptyState" style="display: none;">
|
||||||
|
<div class="empty-icon">📷</div>
|
||||||
|
<h3>Нет изображений</h3>
|
||||||
|
<p>Загрузите изображения или выберите другую папку</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="media-status">
|
||||||
|
<div class="status-left" id="statusInfo">
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
<div class="status-right">
|
||||||
|
<span id="totalFiles">0 файлов</span>
|
||||||
|
<span id="totalSize">0 KB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Drop Overlay -->
|
||||||
|
<div class="drop-overlay" id="dropOverlay">
|
||||||
|
📤 Отпустите файлы для загрузки
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal -->
|
||||||
|
<div class="media-modal" id="mediaModal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<img class="modal-image" id="modalImage" src="" alt="">
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="action-btn" onclick="closeModal()">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
class MediaManager {
|
||||||
|
constructor() {
|
||||||
|
this.selectedFiles = new Set();
|
||||||
|
this.currentFolder = 'all';
|
||||||
|
this.currentView = 'grid';
|
||||||
|
this.allFiles = [];
|
||||||
|
this.filteredFiles = [];
|
||||||
|
|
||||||
|
this.initializeEventListeners();
|
||||||
|
this.loadFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
initializeEventListeners() {
|
||||||
|
// File upload
|
||||||
|
document.getElementById('fileInput').addEventListener('change', (e) => {
|
||||||
|
this.handleFileUpload(e.target.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
document.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('dropOverlay').classList.add('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('dragleave', (e) => {
|
||||||
|
if (!e.relatedTarget) {
|
||||||
|
document.getElementById('dropOverlay').classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById('dropOverlay').classList.remove('active');
|
||||||
|
if (e.dataTransfer.files.length > 0) {
|
||||||
|
this.handleFileUpload(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Search
|
||||||
|
document.getElementById('searchBox').addEventListener('input', (e) => {
|
||||||
|
this.filterFiles(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// View toggle
|
||||||
|
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.view-btn').forEach(b => b.classList.remove('active'));
|
||||||
|
btn.classList.add('active');
|
||||||
|
this.currentView = btn.dataset.view;
|
||||||
|
this.renderFiles();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Folder selection
|
||||||
|
document.querySelectorAll('.folder-item').forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
document.querySelectorAll('.folder-item').forEach(i => i.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
this.currentFolder = item.dataset.folder;
|
||||||
|
this.filterFiles();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Selection actions
|
||||||
|
document.getElementById('useSelectedBtn').addEventListener('click', () => {
|
||||||
|
this.useSelectedFiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('deleteSelectedBtn').addEventListener('click', () => {
|
||||||
|
this.deleteSelectedFiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal close
|
||||||
|
document.getElementById('mediaModal').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'mediaModal') {
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadFiles() {
|
||||||
|
try {
|
||||||
|
this.showLoading(true);
|
||||||
|
const response = await fetch('/api/images/gallery');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.allFiles = data.data.map(file => ({
|
||||||
|
...file,
|
||||||
|
id: file.name,
|
||||||
|
url: file.path,
|
||||||
|
folder: file.folder || 'general'
|
||||||
|
}));
|
||||||
|
this.filterFiles();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Ошибка загрузки файлов');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Ошибка загрузки файлов:', error);
|
||||||
|
this.showError('Ошибка загрузки файлов: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
this.showLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterFiles(search = '') {
|
||||||
|
let files = this.allFiles;
|
||||||
|
|
||||||
|
// Фильтр по папке
|
||||||
|
if (this.currentFolder !== 'all') {
|
||||||
|
files = files.filter(file => file.folder === this.currentFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по поиску
|
||||||
|
if (search) {
|
||||||
|
const searchTerm = search.toLowerCase();
|
||||||
|
files = files.filter(file =>
|
||||||
|
file.name.toLowerCase().includes(searchTerm)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.filteredFiles = files;
|
||||||
|
this.renderFiles();
|
||||||
|
this.updateStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderFiles() {
|
||||||
|
const grid = document.getElementById('mediaGrid');
|
||||||
|
const empty = document.getElementById('emptyState');
|
||||||
|
|
||||||
|
if (this.filteredFiles.length === 0) {
|
||||||
|
grid.style.display = 'none';
|
||||||
|
empty.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
grid.style.display = this.currentView === 'grid' ? 'grid' : 'flex';
|
||||||
|
grid.className = this.currentView === 'grid' ? 'gallery-grid' : 'gallery-list';
|
||||||
|
empty.style.display = 'none';
|
||||||
|
|
||||||
|
grid.innerHTML = this.filteredFiles.map(file => {
|
||||||
|
const isSelected = this.selectedFiles.has(file.id);
|
||||||
|
const sizeText = this.formatFileSize(file.size);
|
||||||
|
|
||||||
|
if (this.currentView === 'grid') {
|
||||||
|
return `
|
||||||
|
<div class="media-item ${isSelected ? 'selected' : ''}"
|
||||||
|
data-id="${file.id}" onclick="mediaManager.selectFile('${file.id}')">
|
||||||
|
<img class="media-preview" src="${file.url}" alt="${file.name}"
|
||||||
|
onerror="this.src='/images/placeholders/no-image.png'">
|
||||||
|
<div class="media-info">
|
||||||
|
<div class="media-name" title="${file.name}">${file.name}</div>
|
||||||
|
<div class="media-size">${sizeText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
return `
|
||||||
|
<div class="media-item list-item ${isSelected ? 'selected' : ''}"
|
||||||
|
data-id="${file.id}">
|
||||||
|
<img class="list-preview" src="${file.url}" alt="${file.name}"
|
||||||
|
onerror="this.src='/images/placeholders/no-image.png'">
|
||||||
|
<div class="list-info">
|
||||||
|
<div class="media-name">${file.name}</div>
|
||||||
|
<div class="media-size">${sizeText} • ${file.folder}</div>
|
||||||
|
</div>
|
||||||
|
<div class="list-actions">
|
||||||
|
<button class="action-btn" onclick="mediaManager.selectFile('${file.id}')">
|
||||||
|
${isSelected ? 'Отменить' : 'Выбрать'}
|
||||||
|
</button>
|
||||||
|
<button class="action-btn" onclick="mediaManager.previewFile('${file.id}')">Просмотр</button>
|
||||||
|
<button class="action-btn delete" onclick="mediaManager.deleteFile('${file.id}')">Удалить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
selectFile(fileId) {
|
||||||
|
if (this.selectedFiles.has(fileId)) {
|
||||||
|
this.selectedFiles.delete(fileId);
|
||||||
|
} else {
|
||||||
|
this.selectedFiles.add(fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateSelection();
|
||||||
|
this.renderFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSelection() {
|
||||||
|
const count = this.selectedFiles.size;
|
||||||
|
const actions = document.getElementById('selectionActions');
|
||||||
|
const countEl = document.getElementById('selectionCount');
|
||||||
|
|
||||||
|
if (count > 0) {
|
||||||
|
actions.classList.add('visible');
|
||||||
|
countEl.textContent = `${count} файл${count > 1 ? (count > 4 ? 'ов' : 'а') : ''} выбрано`;
|
||||||
|
} else {
|
||||||
|
actions.classList.remove('visible');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previewFile(fileId) {
|
||||||
|
const file = this.allFiles.find(f => f.id === fileId);
|
||||||
|
if (file) {
|
||||||
|
document.getElementById('modalImage').src = file.url;
|
||||||
|
document.getElementById('mediaModal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal() {
|
||||||
|
document.getElementById('mediaModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(fileId) {
|
||||||
|
if (!confirm('Удалить этот файл?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/images/delete/${fileId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
this.selectedFiles.delete(fileId);
|
||||||
|
await this.loadFiles();
|
||||||
|
this.showSuccess('Файл удален');
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showError('Ошибка удаления: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteSelectedFiles() {
|
||||||
|
if (this.selectedFiles.size === 0) return;
|
||||||
|
|
||||||
|
if (!confirm(`Удалить ${this.selectedFiles.size} файл(ов)?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const promises = Array.from(this.selectedFiles).map(fileId =>
|
||||||
|
fetch(`/api/images/delete/${fileId}`, { method: 'DELETE' })
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
this.selectedFiles.clear();
|
||||||
|
await this.loadFiles();
|
||||||
|
this.showSuccess('Файлы удалены');
|
||||||
|
} catch (error) {
|
||||||
|
this.showError('Ошибка удаления: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFileUpload(files) {
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
Array.from(files).forEach(file => {
|
||||||
|
formData.append('images', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Добавляем текущую папку
|
||||||
|
formData.append('folder', this.currentFolder === 'all' ? 'general' : this.currentFolder);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.showLoading(true, 'Загрузка файлов...');
|
||||||
|
|
||||||
|
const response = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
await this.loadFiles();
|
||||||
|
this.showSuccess(`Загружено ${files.length} файл(ов)`);
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.showError('Ошибка загрузки: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
this.showLoading(false);
|
||||||
|
document.getElementById('fileInput').value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useSelectedFiles() {
|
||||||
|
const selectedData = Array.from(this.selectedFiles).map(fileId => {
|
||||||
|
const file = this.allFiles.find(f => f.id === fileId);
|
||||||
|
return {
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
url: file.url,
|
||||||
|
path: file.path
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отправляем сообщение родительскому окну (если в iframe)
|
||||||
|
if (window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'media-manager-selection',
|
||||||
|
files: selectedData
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Или вызываем callback если определен
|
||||||
|
if (window.mediaManagerCallback) {
|
||||||
|
window.mediaManagerCallback(selectedData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus() {
|
||||||
|
const total = this.filteredFiles.length;
|
||||||
|
const totalSize = this.filteredFiles.reduce((sum, file) => sum + (file.size || 0), 0);
|
||||||
|
|
||||||
|
document.getElementById('statusInfo').textContent =
|
||||||
|
`Папка: ${this.getFolderName(this.currentFolder)}`;
|
||||||
|
document.getElementById('totalFiles').textContent = `${total} файл${total !== 1 ? (total > 4 ? 'ов' : 'а') : ''}`;
|
||||||
|
document.getElementById('totalSize').textContent = this.formatFileSize(totalSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
getFolderName(folder) {
|
||||||
|
const names = {
|
||||||
|
'all': 'Все файлы',
|
||||||
|
'routes': 'Маршруты',
|
||||||
|
'guides': 'Гиды',
|
||||||
|
'articles': 'Статьи',
|
||||||
|
'general': 'Общие'
|
||||||
|
};
|
||||||
|
return names[folder] || folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (!bytes) return '0 B';
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||||
|
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
showLoading(show, text = 'Загрузка...') {
|
||||||
|
const loading = document.getElementById('loadingIndicator');
|
||||||
|
if (show) {
|
||||||
|
loading.style.display = 'block';
|
||||||
|
loading.querySelector('div:last-child').textContent = text;
|
||||||
|
} else {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
alert('Ошибка: ' + message);
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(message) {
|
||||||
|
// Можно заменить на toast уведомление
|
||||||
|
console.log('Успех:', message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
const mediaManager = new MediaManager();
|
||||||
|
|
||||||
|
// Глобальные функции для обратной совместимости
|
||||||
|
window.selectFile = (id) => mediaManager.selectFile(id);
|
||||||
|
window.previewFile = (id) => mediaManager.previewFile(id);
|
||||||
|
window.deleteFile = (id) => mediaManager.deleteFile(id);
|
||||||
|
window.closeModal = () => mediaManager.closeModal();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -161,6 +161,10 @@ const settingsRouter = (await import('./routes/settings.js')).default;
|
|||||||
const ratingsRouter = (await import('./routes/ratings.js')).default;
|
const ratingsRouter = (await import('./routes/ratings.js')).default;
|
||||||
const imagesRouter = (await import('./routes/images.js')).default;
|
const imagesRouter = (await import('./routes/images.js')).default;
|
||||||
const crudRouter = (await import('./routes/crud.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('/', indexRouter);
|
||||||
app.use('/routes', toursRouter);
|
app.use('/routes', toursRouter);
|
||||||
@@ -171,6 +175,10 @@ app.use('/api', ratingsRouter);
|
|||||||
app.use('/', settingsRouter); // Settings routes (CSS and API)
|
app.use('/', settingsRouter); // Settings routes (CSS and API)
|
||||||
app.use('/api/images', imagesRouter); // Image management routes
|
app.use('/api/images', imagesRouter); // Image management routes
|
||||||
app.use('/api/crud', crudRouter); // CRUD API 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
|
// Health check endpoint
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
490
src/components/admin-calendar-page.js
Normal file
490
src/components/admin-calendar-page.js
Normal file
@@ -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 = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Календарь гидов</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.calendar-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.guide-selector {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-grid {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-nav-btn:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-table th {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 600;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-table td {
|
||||||
|
padding: 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
height: 80px;
|
||||||
|
vertical-align: top;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-table td:hover {
|
||||||
|
background: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.calendar-day {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-number {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-status {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.working {
|
||||||
|
background: #28a745;
|
||||||
|
}
|
||||||
|
|
||||||
|
.holiday {
|
||||||
|
background: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-assigned {
|
||||||
|
background: #6c757d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.other-month {
|
||||||
|
color: #ccc;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-color {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="calendar-container">
|
||||||
|
<h1>📅 Календарь рабочих дней гидов</h1>
|
||||||
|
|
||||||
|
<div class="guide-selector">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Выберите гида:</label>
|
||||||
|
<select class="form-select" id="guideSelect">
|
||||||
|
<option value="">-- Выберите гида --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label">Быстрые действия:</label>
|
||||||
|
<div class="btn-group w-100" role="group">
|
||||||
|
<button type="button" class="btn btn-outline-success" onclick="setWorkingDays('weekdays')">Рабочие дни</button>
|
||||||
|
<button type="button" class="btn btn-outline-warning" onclick="setWorkingDays('weekends')">Выходные</button>
|
||||||
|
<button type="button" class="btn btn-outline-info" onclick="setWorkingDays('all')">Весь месяц</button>
|
||||||
|
<button type="button" class="btn btn-outline-danger" onclick="clearMonth()">Очистить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="calendar-grid">
|
||||||
|
<div class="calendar-header">
|
||||||
|
<button class="calendar-nav-btn" onclick="changeMonth(-1)">
|
||||||
|
← Предыдущий
|
||||||
|
</button>
|
||||||
|
<h3 id="currentMonth">Загрузка...</h3>
|
||||||
|
<button class="calendar-nav-btn" onclick="changeMonth(1)">
|
||||||
|
Следующий →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<table class="calendar-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ПН</th>
|
||||||
|
<th>ВТ</th>
|
||||||
|
<th>СР</th>
|
||||||
|
<th>ЧТ</th>
|
||||||
|
<th>ПТ</th>
|
||||||
|
<th>СБ</th>
|
||||||
|
<th>ВС</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="calendarBody">
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #28a745;"></div>
|
||||||
|
<span>Рабочий день</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #dc3545;"></div>
|
||||||
|
<span>Выходной/Праздник</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-color" style="background: #6c757d;"></div>
|
||||||
|
<span>Не назначено</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loadingOverlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 9999;">
|
||||||
|
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 20px; border-radius: 8px;">
|
||||||
|
<div class="spinner-border" role="status">
|
||||||
|
<span class="visually-hidden">Загрузка...</span>
|
||||||
|
</div>
|
||||||
|
<span style="margin-left: 10px;">Обновление календаря...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script>
|
||||||
|
let currentDate = new Date();
|
||||||
|
let guides = [];
|
||||||
|
let selectedGuideId = null;
|
||||||
|
let workingDays = [];
|
||||||
|
let holidays = [];
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
|
||||||
|
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadGuides() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/guides');
|
||||||
|
const data = await response.json();
|
||||||
|
guides = data.success ? data.data : data;
|
||||||
|
|
||||||
|
const select = document.getElementById('guideSelect');
|
||||||
|
select.innerHTML = '<option value="">-- Выберите гида --</option>';
|
||||||
|
|
||||||
|
guides.forEach(guide => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = guide.id;
|
||||||
|
option.textContent = guide.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
select.addEventListener('change', (e) => {
|
||||||
|
selectedGuideId = e.target.value;
|
||||||
|
if (selectedGuideId) {
|
||||||
|
loadCalendarData();
|
||||||
|
} else {
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading guides:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCalendarData() {
|
||||||
|
if (!selectedGuideId) return;
|
||||||
|
|
||||||
|
showLoading(true);
|
||||||
|
try {
|
||||||
|
const month = currentDate.getFullYear() + '-' + String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
|
||||||
|
const [workingResponse, holidaysResponse] = await Promise.all([
|
||||||
|
fetch('/api/guide-working-days?guide_id=' + selectedGuideId + '&month=' + month),
|
||||||
|
fetch('/api/holidays')
|
||||||
|
]);
|
||||||
|
|
||||||
|
workingDays = await workingResponse.json();
|
||||||
|
holidays = await holidaysResponse.json();
|
||||||
|
|
||||||
|
renderCalendar();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading calendar data:', error);
|
||||||
|
}
|
||||||
|
showLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCalendar() {
|
||||||
|
document.getElementById('currentMonth').textContent =
|
||||||
|
monthNames[currentDate.getMonth()] + ' ' + currentDate.getFullYear();
|
||||||
|
|
||||||
|
const calendarBody = document.getElementById('calendarBody');
|
||||||
|
calendarBody.innerHTML = '';
|
||||||
|
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth();
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
||||||
|
const firstDay = new Date(year, month, 1).getDay();
|
||||||
|
const startDay = firstDay === 0 ? 6 : firstDay - 1;
|
||||||
|
|
||||||
|
let date = 1;
|
||||||
|
|
||||||
|
for (let week = 0; week < 6; week++) {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
|
||||||
|
for (let day = 0; day < 7; day++) {
|
||||||
|
const cell = document.createElement('td');
|
||||||
|
|
||||||
|
if (week === 0 && day < startDay) {
|
||||||
|
cell.classList.add('other-month');
|
||||||
|
cell.innerHTML = '<div class="calendar-day"></div>';
|
||||||
|
} else if (date > daysInMonth) {
|
||||||
|
cell.classList.add('other-month');
|
||||||
|
cell.innerHTML = '<div class="calendar-day"></div>';
|
||||||
|
} else {
|
||||||
|
const dayElement = document.createElement('div');
|
||||||
|
dayElement.className = 'calendar-day';
|
||||||
|
dayElement.innerHTML = '<div class="day-number">' + date + '</div>';
|
||||||
|
|
||||||
|
if (selectedGuideId) {
|
||||||
|
const dateStr = year + '-' + String(month + 1).padStart(2, '0') + '-' + String(date).padStart(2, '0');
|
||||||
|
const isWorking = workingDays.some(wd => wd.work_date === dateStr);
|
||||||
|
const isHoliday = holidays.some(h => h.date === dateStr);
|
||||||
|
|
||||||
|
if (isHoliday) {
|
||||||
|
dayElement.innerHTML += '<div class="day-status holiday">Выходной</div>';
|
||||||
|
} else if (isWorking) {
|
||||||
|
dayElement.innerHTML += '<div class="day-status working">Работает</div>';
|
||||||
|
} else {
|
||||||
|
dayElement.innerHTML += '<div class="day-status not-assigned">Не назначено</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.onclick = () => toggleWorkingDay(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
cell.appendChild(dayElement);
|
||||||
|
date++;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.appendChild(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
calendarBody.appendChild(row);
|
||||||
|
|
||||||
|
if (date > daysInMonth) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleWorkingDay(day) {
|
||||||
|
if (!selectedGuideId) return;
|
||||||
|
|
||||||
|
const dateStr = currentDate.getFullYear() + '-' +
|
||||||
|
String(currentDate.getMonth() + 1).padStart(2, '0') + '-' +
|
||||||
|
String(day).padStart(2, '0');
|
||||||
|
|
||||||
|
const isWorking = workingDays.some(wd => wd.work_date === dateStr);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isWorking) {
|
||||||
|
await fetch('/api/guide-working-days', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
guide_id: selectedGuideId,
|
||||||
|
work_date: dateStr
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await fetch('/api/guide-working-days', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
guide_id: selectedGuideId,
|
||||||
|
work_date: dateStr
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadCalendarData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling working day:', error);
|
||||||
|
alert('Ошибка при обновлении календаря');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeMonth(delta) {
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + delta);
|
||||||
|
if (selectedGuideId) {
|
||||||
|
loadCalendarData();
|
||||||
|
} else {
|
||||||
|
renderCalendar();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setWorkingDays(type) {
|
||||||
|
if (!selectedGuideId) {
|
||||||
|
alert('Выберите гида');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = currentDate.getFullYear();
|
||||||
|
const month = currentDate.getMonth() + 1;
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
const dates = [];
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
|
||||||
|
if (type === 'weekdays' && dayOfWeek >= 1 && dayOfWeek <= 5) {
|
||||||
|
dates.push(year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0'));
|
||||||
|
} else if (type === 'weekends' && (dayOfWeek === 0 || dayOfWeek === 6)) {
|
||||||
|
dates.push(year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0'));
|
||||||
|
} else if (type === 'all') {
|
||||||
|
dates.push(year + '-' + String(month).padStart(2, '0') + '-' + String(day).padStart(2, '0'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/guide-working-days/batch', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
guide_id: selectedGuideId,
|
||||||
|
work_dates: dates,
|
||||||
|
action: 'add'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadCalendarData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting working days:', error);
|
||||||
|
alert('Ошибка при обновлении календаря');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearMonth() {
|
||||||
|
if (!selectedGuideId) {
|
||||||
|
alert('Выберите гида');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm('Очистить весь месяц?')) return;
|
||||||
|
|
||||||
|
const month = currentDate.getFullYear() + '-' + String(currentDate.getMonth() + 1).padStart(2, '0');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/guide-working-days/batch', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
guide_id: selectedGuideId,
|
||||||
|
month: month
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadCalendarData();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing month:', error);
|
||||||
|
alert('Ошибка при очистке календаря');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(show) {
|
||||||
|
document.getElementById('loadingOverlay').style.display = show ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
loadGuides();
|
||||||
|
renderCalendar();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
response.send(html);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new: { isVisible: false },
|
||||||
|
edit: { isVisible: false },
|
||||||
|
delete: { isVisible: false },
|
||||||
|
show: { isVisible: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminCalendarPage;
|
||||||
21
src/components/index.js
Normal file
21
src/components/index.js
Normal file
@@ -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 };
|
||||||
@@ -8,6 +8,7 @@ import { Sequelize, DataTypes } from 'sequelize';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
@@ -143,10 +144,10 @@ const Admins = sequelize.define('admins', {
|
|||||||
name: { type: DataTypes.STRING, allowNull: false },
|
name: { type: DataTypes.STRING, allowNull: false },
|
||||||
email: { type: DataTypes.STRING, allowNull: false },
|
email: { type: DataTypes.STRING, allowNull: false },
|
||||||
password: { 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 },
|
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,
|
timestamps: false,
|
||||||
tableName: 'admins'
|
tableName: 'admins'
|
||||||
@@ -196,6 +197,18 @@ const Holidays = sequelize.define('holidays', {
|
|||||||
tableName: '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', {
|
const SiteSettings = sequelize.define('site_settings', {
|
||||||
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||||
@@ -217,6 +230,9 @@ GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
|
|||||||
Guides.hasMany(Holidays, { foreignKey: 'guide_id' });
|
Guides.hasMany(Holidays, { foreignKey: 'guide_id' });
|
||||||
Holidays.belongsTo(Guides, { 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' });
|
Guides.hasMany(Bookings, { foreignKey: 'guide_id' });
|
||||||
Bookings.belongsTo(Guides, { foreignKey: 'guide_id' });
|
Bookings.belongsTo(Guides, { foreignKey: 'guide_id' });
|
||||||
|
|
||||||
@@ -239,11 +255,62 @@ const getRatingStats = async (targetType, targetId) => {
|
|||||||
// Конфигурация AdminJS с ресурсами базы данных
|
// Конфигурация AdminJS с ресурсами базы данных
|
||||||
// Конфигурация AdminJS с ресурсами Sequelize
|
// Конфигурация AdminJS с ресурсами Sequelize
|
||||||
const adminJsOptions = {
|
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: [
|
resources: [
|
||||||
{
|
{
|
||||||
resource: Routes,
|
resource: Routes,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Контент', icon: 'DocumentText' },
|
parent: { name: 'Контент сайта', icon: 'DocumentText' },
|
||||||
listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'],
|
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'],
|
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'],
|
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,
|
resource: Guides,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Персонал', icon: 'Users' },
|
parent: { name: 'Персонал и гиды', icon: 'Users' },
|
||||||
|
id: 'guides',
|
||||||
listProperties: ['id', 'name', 'email', 'specialization', 'experience', 'hourly_rate', 'is_active'],
|
listProperties: ['id', 'name', 'email', 'specialization', 'experience', 'hourly_rate', 'is_active'],
|
||||||
editProperties: ['name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'image_url', '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'],
|
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,
|
resource: Articles,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Контент', icon: 'DocumentText' },
|
parent: { name: 'Контент сайта', icon: 'DocumentText' },
|
||||||
listProperties: ['id', 'title', 'category', 'is_published', 'views', 'created_at'],
|
listProperties: ['id', 'title', 'category', 'is_published', 'views', 'created_at'],
|
||||||
editProperties: ['title', 'excerpt', 'content', 'category', 'image_url', 'is_published'],
|
editProperties: ['title', 'excerpt', 'content', 'category', 'image_url', 'is_published'],
|
||||||
showProperties: ['id', 'title', 'excerpt', 'content', 'category', 'is_published', 'views', 'created_at', 'updated_at'],
|
showProperties: ['id', 'title', 'excerpt', 'content', 'category', 'is_published', 'views', 'created_at', 'updated_at'],
|
||||||
@@ -402,7 +470,7 @@ const adminJsOptions = {
|
|||||||
{
|
{
|
||||||
resource: Bookings,
|
resource: Bookings,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Заказы', icon: 'ShoppingCart' },
|
parent: { name: 'Бронирования', icon: 'ShoppingCart' },
|
||||||
listProperties: ['id', 'customer_name', 'customer_email', 'preferred_date', 'status', 'total_price', 'created_at'],
|
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'],
|
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'],
|
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,
|
resource: Reviews,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Отзывы', icon: 'Star' },
|
parent: { name: 'Отзывы и рейтинги', icon: 'Star' },
|
||||||
listProperties: ['id', 'customer_name', 'rating', 'is_approved', 'created_at'],
|
listProperties: ['id', 'customer_name', 'rating', 'is_approved', 'created_at'],
|
||||||
editProperties: ['customer_name', 'customer_email', 'rating', 'comment', 'is_approved'],
|
editProperties: ['customer_name', 'customer_email', 'rating', 'comment', 'is_approved'],
|
||||||
showProperties: ['id', 'customer_name', 'customer_email', 'rating', 'comment', 'is_approved', 'created_at'],
|
showProperties: ['id', 'customer_name', 'customer_email', 'rating', 'comment', 'is_approved', 'created_at'],
|
||||||
@@ -479,7 +547,7 @@ const adminJsOptions = {
|
|||||||
{
|
{
|
||||||
resource: ContactMessages,
|
resource: ContactMessages,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Сообщения', icon: 'Email' },
|
parent: { name: 'Администрирование', icon: 'Settings' },
|
||||||
listProperties: ['id', 'name', 'email', 'subject', 'status', 'created_at'],
|
listProperties: ['id', 'name', 'email', 'subject', 'status', 'created_at'],
|
||||||
editProperties: ['name', 'email', 'phone', 'subject', 'message', 'status'],
|
editProperties: ['name', 'email', 'phone', 'subject', 'message', 'status'],
|
||||||
showProperties: ['id', 'name', 'email', 'phone', 'subject', 'message', 'status', 'created_at'],
|
showProperties: ['id', 'name', 'email', 'phone', 'subject', 'message', 'status', 'created_at'],
|
||||||
@@ -526,6 +594,7 @@ const adminJsOptions = {
|
|||||||
resource: Admins,
|
resource: Admins,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Администрирование', icon: 'Settings' },
|
parent: { name: 'Администрирование', icon: 'Settings' },
|
||||||
|
id: 'admins',
|
||||||
listProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'created_at'],
|
listProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'created_at'],
|
||||||
editProperties: ['username', 'name', 'email', 'role', 'is_active'],
|
editProperties: ['username', 'name', 'email', 'role', 'is_active'],
|
||||||
showProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'last_login', 'created_at'],
|
showProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'last_login', 'created_at'],
|
||||||
@@ -567,7 +636,7 @@ const adminJsOptions = {
|
|||||||
{
|
{
|
||||||
resource: Ratings,
|
resource: Ratings,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Система рейтингов', icon: 'Star' },
|
parent: { name: 'Отзывы и рейтинги', icon: 'Star' },
|
||||||
listProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
|
listProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
|
||||||
showProperties: ['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'],
|
filterProperties: ['target_type', 'target_id', 'rating'],
|
||||||
@@ -601,7 +670,7 @@ const adminJsOptions = {
|
|||||||
{
|
{
|
||||||
resource: GuideSchedules,
|
resource: GuideSchedules,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Управление гидами', icon: 'Calendar' },
|
parent: { name: 'Персонал и гиды', icon: 'Users' },
|
||||||
listProperties: ['id', 'guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time'],
|
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'],
|
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'],
|
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 },
|
isVisible: { list: false, filter: false, show: true, edit: false },
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
actions: {
|
||||||
|
list: {
|
||||||
|
after: async (response) => {
|
||||||
|
if (response.records) {
|
||||||
|
response.meta = {
|
||||||
|
...response.meta,
|
||||||
|
customHeader: `
|
||||||
|
<div style="margin-bottom: 20px; padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; color: white; box-shadow: 0 4px 15px rgba(0,0,0,0.1);">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<div>
|
||||||
|
<h3 style="margin: 0 0 8px 0; font-size: 24px; font-weight: 700;">📅 Календарь расписания гидов</h3>
|
||||||
|
<p style="margin: 0; opacity: 0.9; font-size: 16px;">Визуализация расписания работы, выходных дней и загруженности всех гидов</p>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 12px;">
|
||||||
|
<a href="/admin/" style="display: inline-block; padding: 12px 24px; background: rgba(255,255,255,0.2); backdrop-filter: blur(10px); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; border: 1px solid rgba(255,255,255,0.3); transition: all 0.3s ease;">🏠 Дашборд с календарем</a>
|
||||||
|
<a href="/admin/calendar-view" target="_blank" style="display: inline-block; padding: 12px 24px; background: rgba(255,255,255,0.9); color: #667eea; text-decoration: none; border-radius: 8px; font-weight: 600; box-shadow: 0 2px 8px rgba(0,0,0,0.15); transition: all 0.3s ease;">📊 Полноэкранный календарь</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
new: { isVisible: true },
|
||||||
|
edit: { isVisible: true },
|
||||||
|
delete: { isVisible: true },
|
||||||
|
show: { isVisible: true }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
resource: Holidays,
|
resource: Holidays,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Управление гидами', icon: 'Calendar' },
|
parent: { name: 'Персонал и гиды', icon: 'Users' },
|
||||||
listProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
|
listProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
|
||||||
editProperties: ['date', 'title', 'type', 'guide_id'],
|
editProperties: ['date', 'title', 'type', 'guide_id'],
|
||||||
showProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
|
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,
|
resource: SiteSettings,
|
||||||
options: {
|
options: {
|
||||||
parent: { name: 'Администрирование', icon: 'Settings' },
|
parent: { name: 'Администрирование', icon: 'Settings' },
|
||||||
|
id: 'site_settings',
|
||||||
listProperties: ['id', 'setting_key', 'setting_value', 'category', 'updated_at'],
|
listProperties: ['id', 'setting_key', 'setting_value', 'category', 'updated_at'],
|
||||||
editProperties: ['setting_key', 'setting_value', 'setting_type', 'description', 'category'],
|
editProperties: ['setting_key', 'setting_value', 'setting_type', 'description', 'category'],
|
||||||
showProperties: ['id', 'setting_key', 'setting_value', 'setting_type', 'description', 'category', 'updated_at'],
|
showProperties: ['id', 'setting_key', 'setting_value', 'setting_type', 'description', 'category', 'updated_at'],
|
||||||
@@ -745,11 +895,161 @@ const adminJsOptions = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
component: false
|
handler: (req, res) => {
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Админ панель - Главная</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8f9fa; margin: 0; padding: 20px; }
|
||||||
|
.dashboard-container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
.dashboard-title { font-size: 32px; font-weight: 600; color: #495057; margin-bottom: 30px; }
|
||||||
|
.tools-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; margin-bottom: 40px; }
|
||||||
|
.tool-card {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.tool-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.15);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
.tool-icon { font-size: 48px; margin-bottom: 15px; }
|
||||||
|
.tool-title { font-size: 20px; font-weight: 600; margin-bottom: 10px; color: #495057; }
|
||||||
|
.tool-description { color: #6c757d; line-height: 1.5; }
|
||||||
|
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
|
||||||
|
.stat-card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.05); text-align: center; }
|
||||||
|
.stat-number { font-size: 24px; font-weight: 700; color: #007bff; }
|
||||||
|
.stat-label { color: #6c757d; margin-top: 5px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="dashboard-container">
|
||||||
|
<h1 class="dashboard-title">🚀 Админ панель Korea Tourism</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-info mb-4">
|
||||||
|
<h6><i class="fas fa-info-circle me-2"></i>Новая функция: Календарь гидов!</h6>
|
||||||
|
<p class="mb-0">Теперь доступен календарь работы гидов с отображением расписания, выходных дней и загруженности.
|
||||||
|
Поиск туров учитывает доступность гидов на конкретную дату.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tools-grid">
|
||||||
|
<a href="/admin/style-editor" class="tool-card">
|
||||||
|
<div class="tool-icon">🎨</div>
|
||||||
|
<div class="tool-title">Редактор стилей</div>
|
||||||
|
<div class="tool-description">Настройка цветов, шрифтов и внешнего вида сайта. Профессиональный редактор с live preview.</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/admin/image-manager" class="tool-card">
|
||||||
|
<div class="tool-icon">🖼️</div>
|
||||||
|
<div class="tool-title">Менеджер изображений</div>
|
||||||
|
<div class="tool-description">Управление изображениями сайта. Загрузка, удаление, просмотр галереи.</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/admin/schedule-manager" class="tool-card">
|
||||||
|
<div class="tool-icon">📅</div>
|
||||||
|
<div class="tool-title">Планировщик смен</div>
|
||||||
|
<div class="tool-description">Планирование рабочих дней гидов на месяц. Массовое управление расписанием.</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="tool-card" style="grid-column: 1/-1;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 15px;">
|
||||||
|
<div>
|
||||||
|
<div class="tool-icon" style="display: inline; margin-right: 10px;">📅</div>
|
||||||
|
<span class="tool-title" style="display: inline;">Календарь гидов</span>
|
||||||
|
</div>
|
||||||
|
<a href="/admin/calendar-view" target="_blank" style="padding: 8px 16px; background: #007bff; color: white; text-decoration: none; border-radius: 4px; font-size: 14px;">↗ Полный вид</a>
|
||||||
|
</div>
|
||||||
|
<iframe src="/admin/calendar-view" style="width: 100%; height: 600px; border: 1px solid #dee2e6; border-radius: 8px;"></iframe>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" onclick="window.location.reload()" class="tool-card">
|
||||||
|
<div class="tool-icon">📊</div>
|
||||||
|
<div class="tool-title">Управление данными</div>
|
||||||
|
<div class="tool-description">Используйте боковое меню для управления маршрутами, гидами, статьями и другими данными.</div>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" id="routes-count">-</div>
|
||||||
|
<div class="stat-label">Маршруты</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" id="guides-count">-</div>
|
||||||
|
<div class="stat-label">Гиды</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" id="articles-count">-</div>
|
||||||
|
<div class="stat-label">Статьи</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-number" id="bookings-count">-</div>
|
||||||
|
<div class="stat-label">Заказы</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Загрузка статистики
|
||||||
|
fetch('/api/stats')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
document.getElementById('routes-count').textContent = data.routes || 0;
|
||||||
|
document.getElementById('guides-count').textContent = data.guides || 0;
|
||||||
|
document.getElementById('articles-count').textContent = data.articles || 0;
|
||||||
|
document.getElementById('bookings-count').textContent = data.bookings || 0;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.log('Stats loading failed'));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
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: {
|
assets: {
|
||||||
styles: ['/css/admin-custom.css'],
|
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'
|
cookiePassword: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024'
|
||||||
}, null, {
|
}, null, {
|
||||||
resave: false,
|
resave: true,
|
||||||
saveUninitialized: false,
|
saveUninitialized: true,
|
||||||
secret: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024',
|
secret: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024',
|
||||||
|
name: 'adminjs-session',
|
||||||
cookie: {
|
cookie: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: false, // Отключаем secure для development
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 часа
|
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');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
1681
src/config/adminjs-simple.js.backup
Normal file
1681
src/config/adminjs-simple.js.backup
Normal file
File diff suppressed because it is too large
Load Diff
35
src/routes/admin-calendar.js
Normal file
35
src/routes/admin-calendar.js
Normal file
@@ -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;
|
||||||
84
src/routes/admin-tools.js
Normal file
84
src/routes/admin-tools.js
Normal file
@@ -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 = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🎨 Редактор стилей</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8f9fa; }
|
||||||
|
.editor-container { min-height: 100vh; }
|
||||||
|
.editor-header { padding: 20px; background: #fff; border-bottom: 1px solid #dee2e6; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.editor-title { margin: 0; color: #495057; font-size: 28px; font-weight: 600; }
|
||||||
|
.editor-subtitle { margin: 8px 0 0 0; color: #6c757d; font-size: 16px; }
|
||||||
|
.editor-frame { width: 100%; height: calc(100vh - 120px); border: none; background: white; }
|
||||||
|
.breadcrumb { padding: 10px 20px; background: #e9ecef; font-size: 14px; color: #6c757d; }
|
||||||
|
.breadcrumb a { color: #007bff; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="editor-container">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/admin">← Админ панель</a> / Редактор стилей
|
||||||
|
</div>
|
||||||
|
<div class="editor-header">
|
||||||
|
<h1 class="editor-title">🎨 Редактор стилей сайта</h1>
|
||||||
|
<p class="editor-subtitle">Настройка внешнего вида, цветов и темы сайта</p>
|
||||||
|
</div>
|
||||||
|
<iframe src="/professional-style-editor.html" class="editor-frame" frameborder="0"></iframe>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
res.send(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Маршрут для менеджера изображений
|
||||||
|
router.get('/admin/image-manager', (req, res) => {
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>🖼️ Менеджер изображений</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f8f9fa; }
|
||||||
|
.manager-container { min-height: 100vh; }
|
||||||
|
.manager-header { padding: 20px; background: #fff; border-bottom: 1px solid #dee2e6; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
|
||||||
|
.manager-title { margin: 0; color: #495057; font-size: 28px; font-weight: 600; }
|
||||||
|
.manager-subtitle { margin: 8px 0 0 0; color: #6c757d; font-size: 16px; }
|
||||||
|
.manager-frame { width: 100%; height: calc(100vh - 120px); border: none; background: white; }
|
||||||
|
.breadcrumb { padding: 10px 20px; background: #e9ecef; font-size: 14px; color: #6c757d; }
|
||||||
|
.breadcrumb a { color: #007bff; text-decoration: none; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="manager-container">
|
||||||
|
<div class="breadcrumb">
|
||||||
|
<a href="/admin">← Админ панель</a> / Менеджер изображений
|
||||||
|
</div>
|
||||||
|
<div class="manager-header">
|
||||||
|
<h1 class="manager-title">🖼️ Менеджер изображений</h1>
|
||||||
|
<p class="manager-subtitle">Управление изображениями, загрузка и галерея</p>
|
||||||
|
</div>
|
||||||
|
<iframe src="/universal-media-manager.html" class="manager-frame" frameborder="0"></iframe>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
res.send(html);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -101,14 +101,63 @@ router.post('/booking', async (req, res) => {
|
|||||||
guide_id
|
guide_id
|
||||||
} = req.body;
|
} = 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(`
|
const booking = await db.query(`
|
||||||
INSERT INTO bookings (
|
INSERT INTO bookings (
|
||||||
route_id, guide_id, customer_name, customer_email,
|
route_id, guide_id, customer_name, customer_email,
|
||||||
customer_phone, preferred_date, group_size,
|
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')
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $6, CURRENT_TIME)
|
||||||
RETURNING id
|
RETURNING id, preferred_date
|
||||||
`, [
|
`, [
|
||||||
route_id, guide_id, customer_name, customer_email,
|
route_id, guide_id, customer_name, customer_email,
|
||||||
customer_phone, preferred_date, group_size,
|
customer_phone, preferred_date, group_size,
|
||||||
@@ -117,14 +166,14 @@ router.post('/booking', async (req, res) => {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Booking request submitted successfully! We will contact you soon.',
|
message: 'Заявка на бронирование успешно отправлена! Мы свяжемся с вами в ближайшее время.',
|
||||||
booking_id: booking.rows[0].id
|
booking_id: booking.rows[0].id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('API error submitting booking:', error);
|
console.error('API error submitting booking:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
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;
|
export default router;
|
||||||
303
src/routes/guide-schedules.js
Normal file
303
src/routes/guide-schedules.js
Normal file
@@ -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;
|
||||||
@@ -14,7 +14,9 @@ const router = express.Router();
|
|||||||
// Настройка multer для загрузки файлов
|
// Настройка multer для загрузки файлов
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: async (req, file, cb) => {
|
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 {
|
try {
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
@@ -25,7 +27,7 @@ const storage = multer.diskStorage({
|
|||||||
filename: (req, file, cb) => {
|
filename: (req, file, cb) => {
|
||||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
const ext = path.extname(file.originalname);
|
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;
|
export default router;
|
||||||
@@ -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;
|
module.exports = router;
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import SiteSettingsHelper from '../helpers/site-settings.js';
|
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 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 на основе настроек сайта
|
* Динамический 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 для получения настроек сайта
|
* API для получения настроек сайта
|
||||||
*/
|
*/
|
||||||
|
|||||||
14
src/routes/test.js
Normal file
14
src/routes/test.js
Normal file
@@ -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;
|
||||||
@@ -6,6 +6,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- Guide Availability Calendar -->
|
||||||
|
<section class="py-4 bg-light">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-8 mx-auto">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header text-center">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-calendar-alt me-2"></i>Календарь доступности гидов</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="guides-calendar-container"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Guides Grid -->
|
<!-- Guides Grid -->
|
||||||
<section class="py-5">
|
<section class="py-5">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -54,26 +54,23 @@
|
|||||||
<div class="card border-0 shadow-lg" data-aos="fade-up">
|
<div class="card border-0 shadow-lg" data-aos="fade-up">
|
||||||
<div class="card-body p-4">
|
<div class="card-body p-4">
|
||||||
<h4 class="text-center mb-4 fw-bold">Найдите своё идеальное корейское приключение</h4>
|
<h4 class="text-center mb-4 fw-bold">Найдите своё идеальное корейское приключение</h4>
|
||||||
<form class="search-form">
|
|
||||||
<div class="row g-3">
|
<!-- Проверка доступности -->
|
||||||
<div class="col-md-4">
|
<div id="availability-checker-container" class="mb-4"></div>
|
||||||
<select class="form-select form-select-lg" name="type">
|
|
||||||
<option value="">Тип тура</option>
|
<!-- Выбор гида -->
|
||||||
<option value="city">Городские туры</option>
|
<div id="guide-selector-container" class="mt-4" style="display: none;"></div>
|
||||||
<option value="mountain">Горные походы</option>
|
|
||||||
<option value="fishing">Рыбалка</option>
|
<!-- Результаты поиска -->
|
||||||
</select>
|
<div id="searchResults" class="mt-4" style="display: none;">
|
||||||
</div>
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<div class="col-md-4">
|
<h5 class="mb-0">Доступные туры</h5>
|
||||||
<input type="date" class="form-control form-control-lg" name="date">
|
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearSearchResults()">
|
||||||
</div>
|
<i class="fas fa-times me-1"></i>Очистить
|
||||||
<div class="col-md-4">
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg w-100">
|
|
||||||
<i class="fas fa-search me-2"></i>Найти туры
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="searchResultsGrid" class="row g-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
<link href="/css/main.css" rel="stylesheet">
|
<link href="/css/main.css" rel="stylesheet">
|
||||||
<!-- Dynamic Site Settings CSS -->
|
<!-- Dynamic Site Settings CSS -->
|
||||||
<link href="/dynamic-styles.css" rel="stylesheet">
|
<link href="/dynamic-styles.css" rel="stylesheet">
|
||||||
|
<!-- Custom User Styles CSS -->
|
||||||
|
<link href="/api/settings/styles/css" rel="stylesheet">
|
||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="<%= title || siteName %>">
|
<meta property="og:title" content="<%= title || siteName %>">
|
||||||
@@ -89,6 +91,11 @@
|
|||||||
<i class="fas fa-user-tie me-1"></i>Гиды
|
<i class="fas fa-user-tie me-1"></i>Гиды
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link <%= page === 'calendar' ? 'active' : '' %>" href="/tours-calendar.html">
|
||||||
|
<i class="fas fa-calendar-alt me-1"></i>Календарь туров
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link <%= page === 'articles' ? 'active' : '' %>" href="/articles">
|
<a class="nav-link <%= page === 'articles' ? 'active' : '' %>" href="/articles">
|
||||||
<i class="fas fa-newspaper me-1"></i>Статьи
|
<i class="fas fa-newspaper me-1"></i>Статьи
|
||||||
@@ -197,6 +204,11 @@
|
|||||||
<script src="/js/main.js"></script>
|
<script src="/js/main.js"></script>
|
||||||
<script src="/js/image-editor.js"></script>
|
<script src="/js/image-editor.js"></script>
|
||||||
|
|
||||||
|
<!-- Компоненты для календаря и бронирования -->
|
||||||
|
<script src="/components/availability-checker.js"></script>
|
||||||
|
<script src="/components/guide-selector.js"></script>
|
||||||
|
<script src="/components/guide-calendar-widget.js"></script>
|
||||||
|
|
||||||
<!-- Initialize AOS -->
|
<!-- Initialize AOS -->
|
||||||
<script>
|
<script>
|
||||||
AOS.init({
|
AOS.init({
|
||||||
|
|||||||
262
views/routes/booking.ejs
Normal file
262
views/routes/booking.ejs
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero-section compact bg-primary text-white py-5">
|
||||||
|
<div class="container text-center">
|
||||||
|
<h1 class="display-4 fw-bold mb-3">Бронирование тура</h1>
|
||||||
|
<p class="lead">Выберите дату и гида для вашего незабываемого путешествия</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="container py-5">
|
||||||
|
<div class="row">
|
||||||
|
<!-- Информация о маршруте -->
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h4 class="mb-0"><%= route.title %></h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<%
|
||||||
|
let placeholderImage = '/images/placeholder.jpg';
|
||||||
|
if (route.type === 'city') {
|
||||||
|
placeholderImage = '/images/city-tour-placeholder.webp';
|
||||||
|
} else if (route.type === 'mountain') {
|
||||||
|
placeholderImage = '/images/mountain-placeholder.jpg';
|
||||||
|
} else if (route.type === 'fishing') {
|
||||||
|
placeholderImage = '/images/fish-placeholder.jpg';
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
<img src="<%= route.image_url || placeholderImage %>"
|
||||||
|
class="img-fluid rounded"
|
||||||
|
alt="<%= route.title %>">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<p class="text-muted mb-3"><%= route.description %></p>
|
||||||
|
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fas fa-clock text-primary me-2"></i>
|
||||||
|
<span><%= route.duration %> дней</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fas fa-users text-primary me-2"></i>
|
||||||
|
<span>До <%= route.max_group_size %> человек</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fas fa-star text-primary me-2"></i>
|
||||||
|
<span><%= route.difficulty_level === 'easy' ? 'Легкий' : route.difficulty_level === 'moderate' ? 'Средний' : 'Сложный' %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<i class="fas fa-tag text-primary me-2"></i>
|
||||||
|
<span class="h5 text-primary mb-0">₩<%= formatCurrency(route.price) %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Выбор даты и проверка доступности -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-calendar-check me-2"></i>Проверка доступности</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="route-availability-checker"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Выбор гида -->
|
||||||
|
<div class="card mb-4" id="guide-selection-card" style="display: none;">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-user-tie me-2"></i>Выбор гида</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="route-guide-selector"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Форма бронирования -->
|
||||||
|
<div class="col-lg-4">
|
||||||
|
<div class="card sticky-top" style="top: 20px;">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-credit-card me-2"></i>Детали бронирования</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form action="/bookings" method="POST" id="mainBookingForm">
|
||||||
|
<input type="hidden" name="route_id" value="<%= route.id %>">
|
||||||
|
<input type="hidden" name="guide_id" id="selectedGuideId">
|
||||||
|
<input type="hidden" name="preferred_date" id="selectedDate">
|
||||||
|
|
||||||
|
<!-- Выбранные детали -->
|
||||||
|
<div id="booking-summary" class="mb-4 p-3 bg-light rounded" style="display: none;">
|
||||||
|
<h6 class="fw-bold mb-2">Выбрано:</h6>
|
||||||
|
<div id="summary-content"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="people_count" class="form-label">Количество человек</label>
|
||||||
|
<input type="number" class="form-control" name="people_count" id="people_count"
|
||||||
|
min="1" max="<%= route.max_group_size %>" value="1" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="customer_name" class="form-label">Ваше имя *</label>
|
||||||
|
<input type="text" class="form-control" name="customer_name" id="customer_name" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="customer_email" class="form-label">Email *</label>
|
||||||
|
<input type="email" class="form-control" name="customer_email" id="customer_email" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="customer_phone" class="form-label">Телефон *</label>
|
||||||
|
<input type="tel" class="form-control" name="customer_phone" id="customer_phone" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="special_requirements" class="form-label">Особые пожелания</label>
|
||||||
|
<textarea class="form-control" name="special_requirements" id="special_requirements" rows="3"
|
||||||
|
placeholder="Любые специальные запросы или требования..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Итоговая стоимость -->
|
||||||
|
<div class="mb-4 p-3 bg-primary bg-opacity-10 rounded">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<span class="fw-bold">Итого:</span>
|
||||||
|
<span class="h5 text-primary mb-0" id="total-price">₩<%= formatCurrency(route.price) %></span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">За <span id="people-display">1</span> человека</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button type="submit" class="btn btn-primary btn-lg" id="submitBookingBtn" disabled>
|
||||||
|
<i class="fas fa-credit-card me-2"></i>Забронировать тур
|
||||||
|
</button>
|
||||||
|
<a href="/routes/<%= route.id %>" class="btn btn-outline-secondary">
|
||||||
|
<i class="fas fa-arrow-left me-2"></i>Назад к описанию
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const routePrice = <%= route.price %>;
|
||||||
|
const maxGroupSize = <%= route.max_group_size %>;
|
||||||
|
|
||||||
|
// Компонент проверки доступности
|
||||||
|
const availabilityChecker = new AvailabilityChecker({
|
||||||
|
container: document.getElementById('route-availability-checker'),
|
||||||
|
mode: 'detailed',
|
||||||
|
showSuggestions: true,
|
||||||
|
onAvailabilityCheck: function(result) {
|
||||||
|
if (result.availableGuides && result.availableGuides.length > 0) {
|
||||||
|
showGuideSelection(result.availableGuides, result.date);
|
||||||
|
} else {
|
||||||
|
hideGuideSelection();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция показа секции выбора гида
|
||||||
|
function showGuideSelection(availableGuides, selectedDate) {
|
||||||
|
const guideCard = document.getElementById('guide-selection-card');
|
||||||
|
const guideSelectorContainer = document.getElementById('route-guide-selector');
|
||||||
|
|
||||||
|
guideCard.style.display = 'block';
|
||||||
|
|
||||||
|
const guideSelector = new GuideSelector({
|
||||||
|
container: guideSelectorContainer,
|
||||||
|
mode: 'booking',
|
||||||
|
showAvailability: false,
|
||||||
|
availableGuides: availableGuides,
|
||||||
|
selectedDate: selectedDate,
|
||||||
|
onGuideSelect: function(guide) {
|
||||||
|
updateBookingSummary(guide, selectedDate);
|
||||||
|
enableBookingForm(guide.id, selectedDate);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция скрытия секции выбора гида
|
||||||
|
function hideGuideSelection() {
|
||||||
|
const guideCard = document.getElementById('guide-selection-card');
|
||||||
|
guideCard.style.display = 'none';
|
||||||
|
disableBookingForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление сводки бронирования
|
||||||
|
function updateBookingSummary(guide, date) {
|
||||||
|
const summaryContainer = document.getElementById('booking-summary');
|
||||||
|
const summaryContent = document.getElementById('summary-content');
|
||||||
|
|
||||||
|
summaryContent.innerHTML = `
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Дата:</strong> ${formatDate(date)}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Гид:</strong> ${guide.name}
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<strong>Специализация:</strong> ${guide.specialization || 'Универсальный'}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
summaryContainer.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Активация формы бронирования
|
||||||
|
function enableBookingForm(guideId, date) {
|
||||||
|
document.getElementById('selectedGuideId').value = guideId;
|
||||||
|
document.getElementById('selectedDate').value = date;
|
||||||
|
document.getElementById('submitBookingBtn').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Деактивация формы бронирования
|
||||||
|
function disableBookingForm() {
|
||||||
|
document.getElementById('selectedGuideId').value = '';
|
||||||
|
document.getElementById('selectedDate').value = '';
|
||||||
|
document.getElementById('submitBookingBtn').disabled = true;
|
||||||
|
document.getElementById('booking-summary').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление общей стоимости
|
||||||
|
const peopleCountInput = document.getElementById('people_count');
|
||||||
|
const totalPriceElement = document.getElementById('total-price');
|
||||||
|
const peopleDisplayElement = document.getElementById('people-display');
|
||||||
|
|
||||||
|
peopleCountInput.addEventListener('input', function() {
|
||||||
|
const peopleCount = parseInt(this.value) || 1;
|
||||||
|
const totalPrice = routePrice * peopleCount;
|
||||||
|
|
||||||
|
totalPriceElement.textContent = `₩${new Intl.NumberFormat('ru-RU').format(totalPrice)}`;
|
||||||
|
peopleDisplayElement.textContent = peopleCount;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Функция форматирования даты
|
||||||
|
function formatDate(dateString) {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('ru-RU', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<strong class="d-block">Максимум участников</strong>
|
<strong class="d-block">Максимум участников</strong>
|
||||||
<span><%= route.max_group_size %> человек</span>
|
<span><%= route.max_group_size %> человек</span>
|
||||||
</div>
|
</div>
|
||||||
<a href="/contact" class="btn btn-primary w-100">
|
<a href="/routes/<%= route.id %>/booking" class="btn btn-primary w-100">
|
||||||
<i class="fas fa-calendar-plus me-1"></i>Забронировать
|
<i class="fas fa-calendar-plus me-1"></i>Забронировать
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user