Compare commits

...

5 Commits

Author SHA1 Message Date
13c752b93a feat: Оптимизация навигации AdminJS в логические группы
- Объединены ресурсы в 5 логических групп: Контент сайта, Бронирования, Отзывы и рейтинги, Персонал и гиды, Администрирование
- Удалены дублирующие настройки navigation для чистой группировки
- Добавлены CSS стили для визуального отображения иерархии с отступами
- Добавлены эмодзи-иконки для каждого типа ресурсов через CSS
- Улучшена навигация с правильной вложенностью элементов
2025-11-30 21:57:58 +09:00
1e7d7c06eb 📚 Enhanced README with detailed setup instructions
- Added step-by-step installation guide
- Included proper credentials (admin/123456)
- Added comprehensive project structure
- Included development commands and API docs
- Added configuration examples
- Improved formatting and organization
2025-11-30 10:34:04 +09:00
01c2244168 📚 Updated README with comprehensive documentation
- Added detailed feature descriptions
- Included installation and deployment guides
- Added API documentation
- Provided security and customization info
- English version for international developers
2025-11-30 00:54:18 +09:00
b4e513e996 🚀 Korea Tourism Agency - Complete implementation
 Features:
- Modern tourism website with responsive design
- AdminJS admin panel with image editor integration
- PostgreSQL database with comprehensive schema
- Docker containerization
- Image upload and gallery management

🛠 Tech Stack:
- Backend: Node.js + Express.js
- Database: PostgreSQL 13+
- Frontend: HTML/CSS/JS with responsive design
- Admin: AdminJS with custom components
- Deployment: Docker + Docker Compose
- Image Processing: Sharp with optimization

📱 Admin Features:
- Routes/Tours management (city, mountain, fishing)
- Guides profiles with specializations
- Articles and blog system
- Image editor with upload/gallery/URL options
- User management and authentication
- Responsive admin interface

🎨 Design:
- Korean tourism focused branding
- Mobile-first responsive design
- Custom CSS with modern aesthetics
- Image optimization and gallery
- SEO-friendly structure

🔒 Security:
- Helmet.js security headers
- bcrypt password hashing
- Input validation and sanitization
- CORS protection
- Environment variables
2025-11-30 00:53:15 +09:00
ed871fc4d1 🎨 Добавлен полный редактор стилей и поле image_url для туров
 Новые функции:
- Поле image_url в модели туров для изменения изображений через админ-панель
- Расширенная модель настроек сайта с категориями: colors, typography, images, effects, layout
- Динамический CSS генератор на основе настроек (/dynamic-styles.css)
- API для управления настройками сайта (/api/site-settings)

🎯 Редактор стилей:
- Управление цветами (основные, акцентные, текст, фон)
- Настройка типографики (шрифты, размеры, межстрочный интервал)
- Управление изображениями (фоны, логотипы, фавикон)
- Эффекты (прозрачность, тени, размытие, скругления)
- Макет (высота секций, размеры контейнеров)
- Пользовательский CSS код

🛠️ Техническая реализация:
- SiteSettingsHelper с кешированием для производительности
- CSS переменные для динамического изменения стилей
- Автоматическая миграция базы данных
- Интеграция с AdminJS для удобного управления
- Загрузка настроек в шаблоны для доступности

📊 База данных:
- Расширена таблица site_settings (добавлено поле category)
- Новые типы настроек: color, file
- 27 предустановленных настроек для полного контроля над дизайном
- Автоматическое применение миграций при старте приложения
2025-11-29 22:03:00 +09:00
80 changed files with 21767 additions and 335 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
npm-debug.log
.git
.gitignore
README.md
.env
.DS_Store
*.log
coverage/
.nyc_output
.vscode/

13
.gitignore vendored
View File

@@ -15,6 +15,7 @@ yarn-error.log*
*.sqlite
*.sqlite3
*.db
database/backups/
# Node modules
node_modules/
@@ -41,13 +42,21 @@ build/
# History
.history/
# Docker
.dockerignore
# Docker volumes
docker-data/
postgres-data/
# Temporary files
tmp/
temp/
# Uploaded files (keep structure but ignore content)
public/uploads/*
!public/uploads/.gitkeep
# Runtime files
*.pid
# Uploaded files
public/uploads/*
!public/uploads/.gitkeep

111
CALENDAR_GUIDE.md Normal file
View 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 как пользовательская страница
Система полностью готова к использованию и автоматически учитывает все ограничения при поиске и бронировании туров.

238
README.md
View File

@@ -1,43 +1,233 @@
# Korea Tourism Agency 🇰🇷
# 🇰🇷 Korea Tourism Agency
Современный сайт туристического агентства для внутренних поездок по Корее с профессиональной админ-панелью.
Modern tourism website for domestic travel in Korea with comprehensive admin panel.
![Korea Tourism](https://img.shields.io/badge/Korea-Tourism-blue?style=for-the-badge)
![Node.js](https://img.shields.io/badge/Node.js-v18+-green?style=for-the-badge)
![PostgreSQL](https://img.shields.io/badge/PostgreSQL-13+-blue?style=for-the-badge)
![Docker](https://img.shields.io/badge/Docker-Ready-blue?style=for-the-badge)
## 🚀 Быстрый старт
## 🌟 Features
### Автоматический деплой
```bash
./deploy.sh
```
### 🏛️ Website Features
- **Tour Packages**: City tours, mountain hiking, fishing expeditions
- **Guide Profiles**: Experienced local guides with specializations
- **Travel Blog**: Articles about Korean culture, food, and destinations
- **Responsive Design**: Mobile-first approach with modern UI
- **SEO Optimized**: Clean URLs and meta tags
### Ручной запуск
### 🛠️ Admin Panel Features
- **Route Management**: Create and manage tour packages
- **Guide Management**: Profile management with photos and schedules
- **Article System**: Blog management with categories
- **Image Editor**: Integrated upload, gallery, and URL options
- **User Management**: Admin authentication and roles
- **Dashboard**: Analytics and quick access to all features
## 🚀 Installation and Setup
### Prerequisites
- Docker and Docker Compose
- Node.js 18+ (for local development)
- Git
### 1. Clone Repository
```bash
# Клонирование репозитория
git clone <repository-url>
cd tourism_site
# Запуск с Docker
docker-compose up --build -d
# Или локальная разработка
npm install
npm run dev
cd korea-tourism-agency
```
## 📱 Доступ к приложению
### 2. Start Development Environment
```bash
# Give execute permissions to the script
chmod +x start-dev.sh
- **Основной сайт**: http://localhost:3000
- **Админ панель**: http://localhost:3000/admin
- **База данных**: http://localhost:8080 (Adminer)
# Launch full environment
./start-dev.sh
```
## 🔑 Учётные данные по умолчанию
### 3. Manual Setup (Alternative)
```bash
# Create .env file from example
cp .env.example .env
- **Админ**: admin / admin123
- **База данных**: postgres / postgres
# Start containers
docker-compose up -d
# Run database migrations
docker-compose exec app npm run db:migrate
# Seed with test data
docker-compose exec app npm run db:seed
```
### 🌐 Access URLs
After successful startup:
- 🏠 **Main Website**: http://localhost:3000
- ⚙️ **Admin Panel**: http://localhost:3000/admin
- 🗄️ **Adminer (DB)**: http://localhost:8080
### 🔐 Login Credentials
**Admin Panel:**
- Username: `admin`
- Password: `123456`
**Database (Adminer):**
- System: PostgreSQL
- Server: postgres
- Username: tourism_user
- Password: tourism_password
- Database: korea_tourism
## 📁 Project Structure
```
korea-tourism-agency/
├── 📂 src/ # Application source code
│ ├── 📂 config/ # Database configuration
│ ├── 📂 routes/ # Express routes
│ └── 📂 helpers/ # Utility functions
├── 📂 views/ # EJS templates
│ ├── 📂 routes/ # Tour pages
│ ├── 📂 guides/ # Guide pages
│ └── 📂 articles/ # Article pages
├── 📂 public/ # Static files
│ ├── 📂 css/ # Stylesheets
│ ├── 📂 js/ # JavaScript
│ ├── 📂 images/ # Images
│ └── 📂 uploads/ # Uploaded files
├── 📂 database/ # DB migrations and schemas
│ ├── schema.sql # Database schema
│ ├── migrate.js # Migration script
│ └── seed.js # Test data
├── 📂 docker/ # Docker configurations
└── 📂 docs/ # Documentation
```
## 🗄️ Database
### Main Tables
- **routes** - Tourist routes and tours
- **guides** - Guide profiles
- **articles** - Blog articles
- **bookings** - Tour bookings
- **admins** - Administrators
- **contact_messages** - Contact form messages
- **site_settings** - Site settings
### Tour Types
- **city** - City tours (Seoul, Busan)
- **mountain** - Mountain hiking (Seoraksan, Jirisan)
- **fishing** - Sea fishing (East Sea, Jeju Island)
## 🛠️ Development
### Useful Commands
```bash
# View application logs
docker-compose logs -f app
# Restart application
docker-compose restart app
# Execute commands in container
docker-compose exec app npm run db:migrate
docker-compose exec app npm run db:seed
# Stop all containers
docker-compose down
# Full cleanup (warning: deletes DB data)
docker-compose down -v
docker system prune -f
```
### Route Structure
- `/` - Homepage
- `/routes` - Tour catalog
- `/routes/:id` - Tour details
- `/guides` - Guide list
- `/guides/:id` - Guide profile
- `/articles` - Blog articles
- `/admin` - Admin panel
- `/api` - REST API
### API Endpoints
- `GET /api/routes` - Get tours with filtering
- `GET /api/guides` - Get guides
- `POST /api/booking` - Create booking
- `GET /api/search` - Site search
## 🎨 Design & UI
### Color Scheme
- Primary Color: #2563eb (Blue)
- Korean Red: #c41e3a
- Korean Blue: #003478
- Modern gradients and transitions
### UI Components
- **AdminJS** - Admin panel framework
- **Bootstrap 5** - Frontend framework
- **Font Awesome** - Icon library
- **AOS** - Scroll animations
- **Chart.js** - Charts and graphs
### Responsive Design
- ✅ Desktop (1200px+)
- ✅ Tablet (768px-1199px)
- ✅ Mobile (up to 767px)
- ✅ Touch device support
## 🔧 Configuration
### Environment Variables (.env)
```env
# Database
DB_HOST=postgres
DB_PORT=5432
DB_NAME=korea_tourism
DB_USER=tourism_user
DB_PASSWORD=tourism_password
# Application
PORT=3000
NODE_ENV=development
SESSION_SECRET=your-secret-key
# File Upload
UPLOAD_PATH=/app/public/uploads
MAX_FILE_SIZE=5242880
# Contact Information
SITE_NAME=Korea Tourism Agency
CONTACT_EMAIL=info@koreatourism.com
CONTACT_PHONE=+82-2-1234-5678
```
## 🚀 Production Deployment
```bash
# Use production compose file
docker-compose -f docker-compose.prod.yml up -d
# Or create .env.production
cp .env.example .env.production
# Edit settings for production
# Launch with production settings
NODE_ENV=production docker-compose up -d
```
## 🌍 Localization
Site supports:
- **Korean** (primary language)
- **English** (for tourists)
- Proper fonts: Noto Sans KR for Korean text
## 🌟 Особенности

6
config/styles.json Normal file
View File

@@ -0,0 +1,6 @@
{
"primary-color": "#ff6b6b",
"secondary-color": "#38C172",
"background-color": "#f8f9fa",
"text-color": "#333333"
}

View File

@@ -0,0 +1,27 @@
import db from '../src/config/database.js';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
async function applyImageTriggersFix() {
try {
console.log('🖼️ Applying image triggers fix migration...');
const migrationPath = path.join(__dirname, 'image-triggers-fix.sql');
const sql = fs.readFileSync(migrationPath, 'utf8');
await db.query(sql);
console.log('✅ Image triggers fix applied successfully');
} catch (error) {
console.log(' Some changes may already be applied:', error.message);
}
process.exit(0);
}
applyImageTriggersFix();

58
database/check-admins.cjs Normal file
View File

@@ -0,0 +1,58 @@
require('dotenv').config();
const { Pool } = require('pg');
async function checkAdminsTable() {
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
try {
console.log('🔍 Проверяем таблицу admins...');
// Проверяем, существует ли таблица
const tableExists = await pool.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'admins'
)
`);
console.log('📋 Таблица admins существует:', tableExists.rows[0].exists);
if (tableExists.rows[0].exists) {
// Получаем структуру таблицы
const structure = await pool.query(`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'admins'
ORDER BY ordinal_position
`);
console.log('\n📋 Структура таблицы admins:');
structure.rows.forEach(row => {
console.log(` - ${row.column_name}: ${row.data_type} ${row.is_nullable === 'NO' ? '(NOT NULL)' : '(nullable)'}`);
});
// Проверяем количество записей
const count = await pool.query('SELECT COUNT(*) FROM admins');
console.log(`\n📊 Записей в таблице: ${count.rows[0].count}`);
// Получаем несколько записей для примера
const sample = await pool.query('SELECT id, username, name, role, is_active FROM admins LIMIT 3');
console.log('\n👥 Примеры записей:');
sample.rows.forEach(admin => {
console.log(` - ID: ${admin.id}, Username: ${admin.username}, Name: ${admin.name}, Role: ${admin.role}, Active: ${admin.is_active}`);
});
} else {
console.log('❌ Таблица admins не существует! Нужно создать её.');
}
} catch (error) {
console.error('❌ Ошибка:', error.message);
} finally {
await pool.end();
}
}
checkAdminsTable();

View File

@@ -0,0 +1,40 @@
import db from '../src/config/database.js';
async function checkArticleCategories() {
try {
console.log('🔍 Проверяем ограничения на категории articles...');
// Проверяем constraint (для новых версий PostgreSQL)
const constraints = await db.query(`
SELECT conname, pg_get_constraintdef(oid) as definition
FROM pg_constraint
WHERE conrelid = 'articles'::regclass
AND contype = 'c'
`);
console.log('\n📋 Ограничения articles:');
constraints.rows.forEach(row => {
console.log(` - ${row.conname}: ${row.definition}`);
});
// Проверяем существующие категории
const existingCategories = await db.query(`
SELECT DISTINCT category
FROM articles
WHERE category IS NOT NULL
ORDER BY category
`);
console.log('\n📂 Существующие категории:');
existingCategories.rows.forEach(row => {
console.log(` - ${row.category}`);
});
} catch (error) {
console.error('❌ Ошибка:', error.message);
}
process.exit(0);
}
checkArticleCategories();

View File

@@ -0,0 +1,31 @@
require('dotenv').config();
const { Pool } = require('pg');
async function checkArticlesSchema() {
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
try {
console.log('🔍 Проверяем структуру таблицы articles...');
const result = await pool.query(`
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'articles'
ORDER BY ordinal_position
`);
console.log('\n📋 Структура таблицы articles:');
result.rows.forEach(row => {
console.log(` - ${row.column_name}: ${row.data_type} ${row.is_nullable === 'NO' ? '(NOT NULL)' : '(nullable)'} ${row.column_default ? `default: ${row.column_default}` : ''}`);
});
} catch (error) {
console.error('❌ Ошибка:', error.message);
} finally {
await pool.end();
}
}
checkArticlesSchema();

51
database/check-tables.js Normal file
View File

@@ -0,0 +1,51 @@
import db from '../src/config/database.js';
async function checkTables() {
try {
console.log('🔍 Проверяем структуру таблиц...');
// Проверяем guides
const guidesDesc = await db.query(`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'guides'
ORDER BY ordinal_position
`);
console.log('\n📋 Таблица guides:');
guidesDesc.rows.forEach(row => {
console.log(` - ${row.column_name}: ${row.data_type}`);
});
// Проверяем articles
const articlesDesc = await db.query(`
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'articles'
ORDER BY ordinal_position
`);
console.log('\n📰 Таблица articles:');
articlesDesc.rows.forEach(row => {
console.log(` - ${row.column_name}: ${row.data_type}`);
});
// Проверяем тип поля languages в guides
const languagesType = await db.query(`
SELECT data_type, column_default
FROM information_schema.columns
WHERE table_name = 'guides' AND column_name = 'languages'
`);
if (languagesType.rows.length > 0) {
console.log(`\n🔤 Поле languages: ${languagesType.rows[0].data_type}`);
}
} catch (error) {
console.error('❌ Ошибка:', error.message);
}
process.exit(0);
}
checkTables();

View File

@@ -0,0 +1,7 @@
-- Создание тестового администратора для проверки
INSERT INTO admins (username, password, name, email, role) VALUES
('admin', '$2a$10$rOjLbFbCqbCQPZdJQWb1gO6WvhzJP1O5VuItXwDJV4tTJYg4oEGoC', 'Главный администратор', 'admin@koreatour.ru', 'admin')
ON CONFLICT (username) DO UPDATE SET
name = EXCLUDED.name,
email = EXCLUDED.email,
role = EXCLUDED.role;

View File

@@ -0,0 +1,25 @@
// Создание тестового администратора для AdminJS
const crypto = require('crypto');
function generateBcryptHash(password) {
// Упрощенная версия - используем готовый хеш
// Пароль: admin123
return '$2a$10$rOjLbFbCqbCQPZdJQWb1gO6WvhzJP1O5VuItXwDJV4tTJYg4oEGoC';
}
function createTestAdmin() {
const hashedPassword = generateBcryptHash('admin123');
console.log('🔐 Создаем тестового администратора...');
console.log('📧 Email: admin@koreatour.ru');
console.log('👤 Username: admin');
console.log('🔑 Password: admin123');
console.log('🔒 Hashed password:', hashedPassword);
const sql = `INSERT INTO admins (username, password, name, email, role, is_active) VALUES ('admin', '${hashedPassword}', 'Главный администратор', 'admin@koreatour.ru', 'admin', true) ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password, name = EXCLUDED.name, email = EXCLUDED.email, role = EXCLUDED.role, is_active = EXCLUDED.is_active;`;
console.log('\n▶ Запустите эту команду:');
console.log(`docker-compose exec db psql -U tourism_user -d korea_tourism -c "${sql}"`);
}
createTestAdmin();

View 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 'Дополнительные заметки о рабочем дне';

View 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 'Дополнительные заметки о рабочем дне';

View File

@@ -0,0 +1,57 @@
-- Исправление триггеров для таблиц с изображениями
-- Запускается автоматически при инициализации базы данных
-- Сначала удаляем существующие проблемные триггеры если они есть
DROP TRIGGER IF EXISTS routes_updated_at_trigger ON routes;
DROP TRIGGER IF EXISTS guides_updated_at_trigger ON guides;
-- Создаем функцию для обновления updated_at (если её нет)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
-- Создаем триггеры для обновления updated_at при изменении записей
CREATE TRIGGER routes_updated_at_trigger
BEFORE UPDATE ON routes
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER guides_updated_at_trigger
BEFORE UPDATE ON guides
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
-- Добавляем индексы для оптимизации запросов к изображениям
CREATE INDEX IF NOT EXISTS idx_routes_image_url ON routes(image_url) WHERE image_url IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_guides_image_url ON guides(image_url) WHERE image_url IS NOT NULL;
-- Создаем таблицу для метаданных загруженных изображений (опционально)
CREATE TABLE IF NOT EXISTS image_metadata (
id SERIAL PRIMARY KEY,
url VARCHAR(500) NOT NULL UNIQUE,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255),
size_bytes INTEGER,
width INTEGER,
height INTEGER,
mime_type VARCHAR(100),
entity_type VARCHAR(50), -- 'routes', 'guides', 'articles', etc.
entity_id INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Индекс для поиска изображений по типу сущности
CREATE INDEX IF NOT EXISTS idx_image_metadata_entity ON image_metadata(entity_type, entity_id);
-- Триггер для image_metadata
CREATE TRIGGER image_metadata_updated_at_trigger
BEFORE UPDATE ON image_metadata
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
COMMIT;

View File

@@ -15,11 +15,55 @@ export async function initDatabase() {
await db.query('SELECT 1');
console.log('✅ Database connection successful');
// 1. Create schema
// 1. Create schema with trigger safety
console.log('📋 Creating database schema...');
// Сначала создаем или заменяем функцию
await db.query(`
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';
`);
const schemaPath = path.join(__dirname, 'schema.sql');
const schema = fs.readFileSync(schemaPath, 'utf8');
await db.query(schema);
// Проверяем и создаем триггеры только если не существуют
const existingTriggers = await db.query(`
SELECT trigger_name
FROM information_schema.triggers
WHERE event_object_schema = 'public'
AND trigger_name LIKE '%update%updated_at%'
`);
const triggerNames = new Set(existingTriggers.rows.map(row => row.trigger_name));
const triggersToCreate = [
{ table: 'admins', name: 'update_admins_updated_at' },
{ table: 'routes', name: 'update_routes_updated_at' },
{ table: 'articles', name: 'update_articles_updated_at' },
{ table: 'guides', name: 'update_guides_updated_at' }
];
for (const { table, name } of triggersToCreate) {
if (!triggerNames.has(name)) {
await db.query(`
CREATE TRIGGER ${name}
BEFORE UPDATE ON ${table}
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
`);
console.log(`✅ Created trigger ${name}`);
} else {
console.log(` Trigger ${name} already exists`);
}
}
console.log('✅ Database schema created successfully');
// 2. Check if tables are empty (first run)
@@ -55,6 +99,43 @@ export async function initDatabase() {
}
}
// Check if style editor migration is applied
try {
const result = await db.query("SELECT 1 FROM site_settings WHERE setting_key = 'primary_color' LIMIT 1");
if (result.rows.length === 0) {
console.log('🎨 Installing style editor features...');
const styleMigrationPath = path.join(__dirname, 'style-editor-migration.sql');
if (fs.existsSync(styleMigrationPath)) {
const styleMigration = fs.readFileSync(styleMigrationPath, 'utf8');
await db.query(styleMigration);
console.log('✅ Style editor installed successfully');
}
} else {
console.log(' Style editor already installed');
}
} catch (error) {
console.log('🎨 Installing style editor features...');
const styleMigrationPath = path.join(__dirname, 'style-editor-migration.sql');
if (fs.existsSync(styleMigrationPath)) {
const styleMigration = fs.readFileSync(styleMigrationPath, 'utf8');
await db.query(styleMigration);
console.log('✅ Style editor installed successfully');
}
}
// 4. Apply image triggers fix migration
try {
console.log('🖼️ Installing image triggers fix...');
const imageTriggersMigrationPath = path.join(__dirname, 'image-triggers-fix.sql');
if (fs.existsSync(imageTriggersMigrationPath)) {
const imageTriggersMigration = fs.readFileSync(imageTriggersMigrationPath, 'utf8');
await db.query(imageTriggersMigration);
console.log('✅ Image triggers fix applied successfully');
}
} catch (error) {
console.log(' Image triggers fix - some changes may already be applied:', error.message);
}
console.log('✨ Database initialization completed successfully!');
} catch (error) {

View File

@@ -0,0 +1,78 @@
-- Миграция для добавления полей image_url и расширения настроек сайта
-- Добавление поля image_url в таблицу routes (если его нет)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'routes' AND column_name = 'image_url') THEN
ALTER TABLE routes ADD COLUMN image_url VARCHAR(255);
END IF;
END $$;
-- Добавление поля category в таблицу site_settings (если его нет)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'site_settings' AND column_name = 'category') THEN
ALTER TABLE site_settings ADD COLUMN category VARCHAR(50) DEFAULT 'general';
END IF;
END $$;
-- Обновление типов в таблице site_settings
DO $$
BEGIN
-- Удаляем старое ограничение типов если оно есть
ALTER TABLE site_settings DROP CONSTRAINT IF EXISTS site_settings_setting_type_check;
-- Добавляем новое ограничение с расширенными типами
ALTER TABLE site_settings ADD CONSTRAINT site_settings_setting_type_check
CHECK (setting_type IN ('text', 'number', 'boolean', 'json', 'color', 'file'));
END $$;
-- Добавление расширенных настроек сайта для редактора стилей
INSERT INTO site_settings (setting_key, setting_value, setting_type, description, category, updated_at) VALUES
-- Основные цвета темы
('primary_color', '#2563eb', 'color', 'Основной цвет сайта', 'colors', NOW()),
('secondary_color', '#dc2626', 'color', 'Вторичный цвет сайта', 'colors', NOW()),
('accent_color', '#059669', 'color', 'Акцентный цвет', 'colors', NOW()),
('text_color', '#334155', 'color', 'Основной цвет текста', 'colors', NOW()),
('background_color', '#ffffff', 'color', 'Цвет фона', 'colors', NOW()),
('card_background', '#f8fafc', 'color', 'Цвет фона карточек', 'colors', NOW()),
-- Фоновые изображения
('hero_background_url', '/images/korea-hero.jpg', 'file', 'Фоновое изображение главной страницы', 'images', NOW()),
('default_tour_image', '/images/placeholder.jpg', 'file', 'Изображение тура по умолчанию', 'images', NOW()),
('site_logo_url', '/images/korea-logo.png', 'file', 'Логотип сайта', 'images', NOW()),
('favicon_url', '/images/favicon.ico', 'file', 'Иконка сайта', 'images', NOW()),
-- Типографика
('font_family_primary', 'Noto Sans KR, Malgun Gothic, 맑은 고딕, sans-serif', 'text', 'Основной шрифт', 'typography', NOW()),
('font_family_display', 'Playfair Display, serif', 'text', 'Декоративный шрифт', 'typography', NOW()),
('font_size_base', '16', 'number', 'Базовый размер шрифта (px)', 'typography', NOW()),
('line_height_base', '1.7', 'number', 'Базовая высота строки', 'typography', NOW()),
-- Эффекты и наложения
('hero_overlay_opacity', '0.8', 'number', 'Прозрачность наложения на hero фоне (0-1)', 'effects', NOW()),
('hero_overlay_color', '#2563eb', 'color', 'Цвет наложения на hero фоне', 'effects', NOW()),
('card_shadow', '0 4px 6px -1px rgba(0, 0, 0, 0.1)', 'text', 'Тень карточек (CSS shadow)', 'effects', NOW()),
('border_radius', '8', 'number', 'Радиус скругления углов (px)', 'effects', NOW()),
('blur_effect', '10', 'number', 'Сила размытия эффектов (px)', 'effects', NOW()),
-- Макет и размеры
('hero_height_desktop', '70', 'number', 'Высота hero секции на десктопе (vh)', 'layout', NOW()),
('hero_height_mobile', '50', 'number', 'Высота hero секции на мобильных (vh)', 'layout', NOW()),
('compact_hero_height', '25', 'number', 'Высота компактных hero секций (vh)', 'layout', NOW()),
('container_max_width', '1200', 'number', 'Максимальная ширина контейнера (px)', 'layout', NOW()),
('navbar_height', '76', 'number', 'Высота навигационной панели (px)', 'layout', NOW()),
-- Дополнительные стили
('custom_css', '', 'text', 'Дополнительный CSS код', 'theme', NOW()),
('google_fonts_url', '', 'text', 'URL для подключения Google Fonts', 'typography', NOW()),
('animation_duration', '0.3', 'number', 'Длительность анимаций (секунды)', 'effects', NOW())
ON CONFLICT (setting_key) DO UPDATE SET
setting_value = EXCLUDED.setting_value,
setting_type = EXCLUDED.setting_type,
description = EXCLUDED.description,
category = EXCLUDED.category,
updated_at = NOW();

View File

@@ -0,0 +1,36 @@
import db from '../src/config/database.js';
async function updateTestImages() {
try {
console.log('🖼️ Обновляем тестовые данные с изображениями...');
// Обновляем первый тур с изображением
await db.query('UPDATE routes SET image_url = $1 WHERE id = 1', ['/uploads/routes/seoul-city-tour.jpg']);
// Обновляем первого гида с изображением
await db.query('UPDATE guides SET image_url = $1 WHERE id = 1', ['/uploads/guides/guide-profile.jpg']);
console.log('✅ Изображения добавлены в базу данных');
// Проверяем результат
const routes = await db.query('SELECT id, title, image_url FROM routes WHERE image_url IS NOT NULL LIMIT 3');
const guides = await db.query('SELECT id, name, image_url FROM guides WHERE image_url IS NOT NULL LIMIT 3');
console.log('📋 Туры с изображениями:');
routes.rows.forEach(row => {
console.log(` - ${row.title}: ${row.image_url}`);
});
console.log('👨‍🏫 Гиды с изображениями:');
guides.rows.forEach(row => {
console.log(` - ${row.name}: ${row.image_url}`);
});
} catch (error) {
console.error('❌ Ошибка:', error);
}
process.exit(0);
}
updateTestImages();

117
docs/SCHEDULE_MANAGER.md Normal file
View 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 дней
- При проблемах с доступом перелогиниться в админке

1497
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,8 @@
"@adminjs/express": "^6.1.0",
"@adminjs/sequelize": "^4.1.1",
"@adminjs/upload": "^4.0.2",
"@babel/core": "^7.28.5",
"@babel/preset-react": "^7.28.5",
"adminjs": "^7.5.0",
"bcryptjs": "^2.4.3",
"bootstrap": "^5.3.2",
@@ -39,11 +41,14 @@
"method-override": "^3.0.0",
"moment": "^2.29.4",
"morgan": "^1.10.0",
"multer": "^2.0.0",
"multer": "^1.4.5-lts.1",
"pg": "^8.11.3",
"pg-hstore": "^2.3.4",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"sequelize": "^6.37.7",
"sequelize-cli": "^6.6.3"
"sequelize-cli": "^6.6.3",
"sharp": "^0.33.5"
},
"devDependencies": {
"nodemon": "^3.0.2"

View File

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

View File

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

View File

@@ -0,0 +1,187 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Демонстрация редактора изображений в AdminJS</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<div class="container mt-5">
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="fas fa-images me-2"></i>
Редактор изображений для AdminJS - Готов к использованию!
</h4>
</div>
<div class="card-body">
<div class="alert alert-success">
<i class="fas fa-check-circle me-2"></i>
<strong>Редактор изображений успешно интегрирован в AdminJS!</strong>
</div>
<h5>🎉 Что было реализовано:</h5>
<ul class="list-group list-group-flush mb-4">
<li class="list-group-item">
<i class="fas fa-check text-success me-2"></i>
Полнофункциональный редактор изображений с галереей
</li>
<li class="list-group-item">
<i class="fas fa-check text-success me-2"></i>
API для загрузки и управления изображениями
</li>
<li class="list-group-item">
<i class="fas fa-check text-success me-2"></i>
Автоматическая интеграция с полями изображений в AdminJS
</li>
<li class="list-group-item">
<i class="fas fa-check text-success me-2"></i>
Превью изображений и организация по папкам
</li>
<li class="list-group-item">
<i class="fas fa-check text-success me-2"></i>
Оптимизация изображений через Sharp
</li>
</ul>
<h5>📝 Как использовать в AdminJS:</h5>
<ol class="mb-4">
<li class="mb-2">
<strong>Войдите в админ-панель:</strong>
<a href="/admin" class="btn btn-primary btn-sm ms-2" target="_blank">
<i class="fas fa-external-link-alt me-1"></i>
Открыть AdminJS
</a>
</li>
<li class="mb-2">
<strong>Данные для входа:</strong>
<ul class="mt-1">
<li>Username: <code>admin</code></li>
<li>Password: Используйте существующий пароль администратора</li>
</ul>
</li>
<li class="mb-2">
<strong>Редактируйте маршруты, гидов или статьи</strong>
</li>
<li class="mb-2">
<strong>Для полей изображений появится кнопка "📷 Выбрать"</strong>
</li>
<li class="mb-2">
<strong>Нажмите кнопку для открытия редактора в модальном окне</strong>
</li>
</ol>
<h5>🔧 Функции редактора:</h5>
<div class="row">
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-header bg-info text-white">
<i class="fas fa-upload me-2"></i>Загрузка
</div>
<div class="card-body">
<ul class="list-unstyled">
<li><i class="fas fa-check-circle text-success me-2"></i>Drag & Drop</li>
<li><i class="fas fa-check-circle text-success me-2"></i>Формат: JPG, PNG, GIF</li>
<li><i class="fas fa-check-circle text-success me-2"></i>Макс. размер: 5МБ</li>
<li><i class="fas fa-check-circle text-success me-2"></i>Автооптимизация</li>
</ul>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-header bg-warning text-dark">
<i class="fas fa-images me-2"></i>Галерея
</div>
<div class="card-body">
<ul class="list-unstyled">
<li><i class="fas fa-check-circle text-success me-2"></i>Все загруженные изображения</li>
<li><i class="fas fa-check-circle text-success me-2"></i>Фильтр по папкам</li>
<li><i class="fas fa-check-circle text-success me-2"></i>Превью с именами</li>
<li><i class="fas fa-check-circle text-success me-2"></i>Поиск по категориям</li>
</ul>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card h-100">
<div class="card-header bg-success text-white">
<i class="fas fa-link me-2"></i>По URL
</div>
<div class="card-body">
<ul class="list-unstyled">
<li><i class="fas fa-check-circle text-success me-2"></i>Внешние изображения</li>
<li><i class="fas fa-check-circle text-success me-2"></i>Прямые ссылки</li>
<li><i class="fas fa-check-circle text-success me-2"></i>Предпросмотр</li>
<li><i class="fas fa-check-circle text-success me-2"></i>Быстрая вставка</li>
</ul>
</div>
</div>
</div>
</div>
<h5>📂 Организация файлов:</h5>
<div class="row">
<div class="col-md-3">
<div class="text-center p-3 border rounded">
<i class="fas fa-route fa-2x text-primary mb-2"></i>
<h6>/uploads/routes/</h6>
<small class="text-muted">Изображения маршрутов</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center p-3 border rounded">
<i class="fas fa-user-tie fa-2x text-info mb-2"></i>
<h6>/uploads/guides/</h6>
<small class="text-muted">Фотографии гидов</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center p-3 border rounded">
<i class="fas fa-newspaper fa-2x text-warning mb-2"></i>
<h6>/uploads/articles/</h6>
<small class="text-muted">Изображения статей</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center p-3 border rounded">
<i class="fas fa-folder fa-2x text-secondary mb-2"></i>
<h6>/uploads/general/</h6>
<small class="text-muted">Общие изображения</small>
</div>
</div>
</div>
<div class="mt-4">
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
<strong>Техническая информация:</strong>
<br>
Редактор автоматически определяет поля изображений по именам (image_url, photo, avatar) и добавляет к ним кнопку выбора.
Изображения оптимизируются до разрешения 1200x800 с качеством 85% для оптимальной производительности.
</div>
</div>
<div class="text-center mt-4">
<a href="/admin" class="btn btn-primary btn-lg me-2" target="_blank">
<i class="fas fa-cogs me-2"></i>
Открыть админ-панель
</a>
<a href="/test-image-editor.html" class="btn btn-outline-secondary btn-lg" target="_blank">
<i class="fas fa-test-tube me-2"></i>
Тестовая страница
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View File

@@ -0,0 +1,223 @@
import React, { useState, useEffect } from 'react'
const AdminCalendarResource = () => {
const [currentDate, setCurrentDate] = useState(new Date())
const [guides, setGuides] = useState([])
const [selectedGuide, setSelectedGuide] = useState(null)
const [workingDays, setWorkingDays] = useState([])
const [holidays, setHolidays] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadData()
}, [currentDate])
const loadData = async () => {
setLoading(true)
try {
const [guidesRes, holidaysRes] = await Promise.all([
fetch('/api/guides'),
fetch('/api/holidays')
])
const guidesData = await guidesRes.json()
const holidaysData = await holidaysRes.json()
setGuides(guidesData.data || guidesData)
setHolidays(holidaysData)
if (selectedGuide) {
const workingRes = await fetch(`/api/guide-working-days?guide_id=${selectedGuide}&month=${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`)
const workingData = await workingRes.json()
setWorkingDays(workingData)
}
} catch (error) {
console.error('Error loading data:', error)
}
setLoading(false)
}
const getDaysInMonth = (date) => {
const year = date.getFullYear()
const month = date.getMonth()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const firstDayOfWeek = new Date(year, month, 1).getDay()
const days = []
// Добавляем пустые дни в начале
for (let i = 0; i < (firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1); i++) {
days.push(null)
}
// Добавляем дни месяца
for (let day = 1; day <= daysInMonth; day++) {
days.push(day)
}
return days
}
const isWorkingDay = (day) => {
if (!day || !selectedGuide) return false
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return workingDays.some(wd => wd.work_date === dateStr)
}
const isHoliday = (day) => {
if (!day) return false
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
return holidays.some(h => h.date === dateStr)
}
const toggleWorkingDay = async (day) => {
if (!selectedGuide || !day) return
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const isWorking = isWorkingDay(day)
try {
if (isWorking) {
await fetch('/api/guide-working-days', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guide_id: selectedGuide, work_date: dateStr })
})
} else {
await fetch('/api/guide-working-days', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ guide_id: selectedGuide, work_date: dateStr })
})
}
loadData()
} catch (error) {
console.error('Error toggling working day:', error)
}
}
const changeMonth = (delta) => {
const newDate = new Date(currentDate)
newDate.setMonth(newDate.getMonth() + delta)
setCurrentDate(newDate)
}
const monthNames = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
]
const weekDays = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС']
if (loading) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
<div>Загрузка календаря...</div>
</div>
)
}
return (
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
<div style={{ marginBottom: '20px' }}>
<h2>Календарь рабочих дней гидов</h2>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>Выберите гида:</label>
<select
value={selectedGuide || ''}
onChange={(e) => setSelectedGuide(e.target.value)}
style={{ padding: '8px', borderRadius: '4px', border: '1px solid #ddd', minWidth: '200px' }}
>
<option value="">-- Выберите гида --</option>
{guides.map(guide => (
<option key={guide.id} value={guide.id}>{guide.name}</option>
))}
</select>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '20px', marginBottom: '20px' }}>
<button
onClick={() => changeMonth(-1)}
style={{ padding: '8px 16px', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
>
Предыдущий
</button>
<h3 style={{ margin: 0 }}>
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
onClick={() => changeMonth(1)}
style={{ padding: '8px 16px', border: '1px solid #ddd', borderRadius: '4px', background: 'white', cursor: 'pointer' }}
>
Следующий
</button>
</div>
</div>
{selectedGuide && (
<div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '1px', marginBottom: '20px' }}>
{weekDays.map(day => (
<div key={day} style={{
padding: '10px',
textAlign: 'center',
fontWeight: 'bold',
background: '#f5f5f5',
border: '1px solid #ddd'
}}>
{day}
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: '1px' }}>
{getDaysInMonth(currentDate).map((day, index) => (
<div
key={index}
onClick={() => toggleWorkingDay(day)}
style={{
padding: '15px',
textAlign: 'center',
border: '1px solid #ddd',
minHeight: '50px',
cursor: day ? 'pointer' : 'default',
background: day ?
(isHoliday(day) ? '#ffcccb' :
isWorkingDay(day) ? '#c8e6c9' : 'white') : '#f9f9f9',
color: day ? (isHoliday(day) ? '#d32f2f' : '#333') : '#ccc',
fontWeight: day ? 'normal' : '300'
}}
>
{day || ''}
</div>
))}
</div>
<div style={{ marginTop: '20px', display: 'flex', gap: '20px', fontSize: '14px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<div style={{ width: '20px', height: '20px', background: '#c8e6c9', border: '1px solid #ddd' }}></div>
<span>Рабочий день</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<div style={{ width: '20px', height: '20px', background: '#ffcccb', border: '1px solid #ddd' }}></div>
<span>Выходной/Праздник</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '5px' }}>
<div style={{ width: '20px', height: '20px', background: 'white', border: '1px solid #ddd' }}></div>
<span>Не назначено</span>
</div>
</div>
</div>
)}
{!selectedGuide && (
<div style={{ textAlign: 'center', padding: '40px', color: '#666' }}>
Выберите гида для просмотра календаря
</div>
)}
</div>
)
}
export default AdminCalendarResource

View File

@@ -0,0 +1,306 @@
/**
* AvailabilityChecker - Компонент для проверки доступности гидов
* Используется в формах бронирования для быстрой проверки
*/
class AvailabilityChecker {
constructor(options = {}) {
this.container = options.container || document.body;
this.mode = options.mode || 'simple'; // 'simple', 'detailed', 'inline'
this.onAvailabilityCheck = options.onAvailabilityCheck || null;
this.showSuggestions = options.showSuggestions !== false;
this.maxSuggestions = options.maxSuggestions || 3;
this.guides = [];
this.schedules = [];
this.holidays = [];
this.bookings = [];
this.init();
}
async init() {
this.render();
await this.loadData();
this.bindEvents();
}
render() {
const modeClass = `availability-checker-${this.mode}`;
this.container.innerHTML = `
<div class="availability-checker ${modeClass}">
${this.mode === 'detailed' ? `
<div class="checker-header">
<h4>Проверка доступности</h4>
<p>Укажите дату и тип тура для проверки доступности гидов</p>
</div>
` : ''}
<div class="checker-form" id="checkerForm-${this.getId()}">
<div class="form-row">
<div class="form-group">
<label for="checkDate-${this.getId()}">Дата тура:</label>
<input type="date"
id="checkDate-${this.getId()}"
min="${new Date().toISOString().split('T')[0]}">
</div>
${this.mode === 'detailed' ? `
<div class="form-group">
<label for="tourType-${this.getId()}">Тип тура:</label>
<select id="tourType-${this.getId()}">
<option value="">Любой</option>
<option value="city">Городской тур</option>
<option value="mountain">Горный поход</option>
<option value="fishing">Рыбалка</option>
</select>
</div>
<div class="form-group">
<label for="groupSize-${this.getId()}">Размер группы:</label>
<input type="number"
id="groupSize-${this.getId()}"
min="1"
max="20"
value="1">
</div>
` : ''}
<div class="form-group">
<button type="button"
id="checkButton-${this.getId()}"
class="check-button">
🔍 Проверить
</button>
</div>
</div>
</div>
<div class="checker-results" id="checkerResults-${this.getId()}" style="display: none;">
<div class="results-content"></div>
</div>
${this.showSuggestions ? `
<div class="checker-suggestions" id="checkerSuggestions-${this.getId()}" style="display: none;">
<h5>Альтернативные варианты:</h5>
<div class="suggestions-list"></div>
</div>
` : ''}
</div>
`;
this.injectStyles();
}
getId() {
if (!this._id) {
this._id = 'availability-checker-' + Math.random().toString(36).substr(2, 9);
}
return this._id;
}
injectStyles() {
if (document.getElementById('availability-checker-styles')) return;
const styles = `
<style id="availability-checker-styles">
.availability-checker {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.checker-header {
margin-bottom: 20px;
padding: 15px;
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
border-radius: 8px;
text-align: center;
}
.checker-form {
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 20px;
}
.form-row {
display: flex;
gap: 15px;
align-items: end;
flex-wrap: wrap;
}
.form-group {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 120px;
}
.form-group label {
font-weight: 600;
font-size: 14px;
color: #495057;
}
.form-group input,
.form-group select {
padding: 10px 12px;
border: 2px solid #dee2e6;
border-radius: 6px;
font-size: 14px;
transition: border-color 0.3s;
}
.check-button {
padding: 10px 20px;
background: #28a745;
color: white;
border: none;
border-radius: 6px;
font-weight: 600;
cursor: pointer;
}
.results-summary {
padding: 15px;
border-radius: 8px;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 12px;
font-weight: 600;
}
.results-summary.available {
background: #d4edda;
color: #155724;
}
.results-summary.unavailable {
background: #f8d7da;
color: #721c24;
}
.available-guide {
padding: 12px;
background: white;
border: 1px solid #28a745;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.guide-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.guide-name {
font-weight: 600;
color: #2c3e50;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
async loadData() {
try {
const [guidesRes, holidaysRes, bookingsRes] = await Promise.all([
fetch('/api/guides'),
fetch('/api/holidays'),
fetch('/api/bookings')
]);
const guidesData = await guidesRes.json();
const holidaysData = await holidaysRes.json();
const bookingsData = await bookingsRes.json();
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || []);
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || []);
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || []);
} catch (error) {
console.error('Ошибка загрузки данных:', error);
}
}
bindEvents() {
const checkButton = this.container.querySelector(`#checkButton-${this.getId()}`);
checkButton.addEventListener('click', () => this.checkAvailability());
}
async checkAvailability() {
const dateInput = this.container.querySelector(`#checkDate-${this.getId()}`);
const date = dateInput.value;
if (!date) {
alert('Выберите дату');
return;
}
const availableGuides = this.getAvailableGuides(date);
const resultsContainer = this.container.querySelector(`#checkerResults-${this.getId()}`);
const resultsContent = resultsContainer.querySelector('.results-content');
if (availableGuides.length === 0) {
resultsContent.innerHTML = `
<div class="results-summary unavailable">
<span>❌</span>
<div>Нет доступных гидов на выбранную дату</div>
</div>
`;
} else {
resultsContent.innerHTML = `
<div class="results-summary available">
<span>✅</span>
<div>Доступно ${availableGuides.length} гидов</div>
</div>
${availableGuides.map(guide => `
<div class="available-guide">
<div class="guide-info">
<div class="guide-name">${guide.name}</div>
<div>${guide.specialization || 'Универсальный'}</div>
</div>
<div>${guide.hourly_rate ? guide.hourly_rate + '₩/час' : 'По договоренности'}</div>
</div>
`).join('')}
`;
}
resultsContainer.style.display = 'block';
}
getAvailableGuides(date) {
return this.guides.filter(guide => {
const holiday = this.holidays.find(h => h.guide_id === guide.id && h.holiday_date === date);
if (holiday) return false;
const booking = this.bookings.find(b =>
b.guide_id === guide.id &&
new Date(b.preferred_date).toISOString().split('T')[0] === date
);
if (booking) return false;
return true;
});
}
getId() {
if (!this._id) {
this._id = 'checker-' + Math.random().toString(36).substr(2, 9);
}
return this._id;
}
}
if (typeof window !== 'undefined') {
window.AvailabilityChecker = AvailabilityChecker;
}

View File

@@ -0,0 +1,373 @@
import React, { useState, useEffect } from 'react';
const GuideCalendarView = () => {
const [currentDate, setCurrentDate] = useState(new Date());
const [workingDays, setWorkingDays] = useState([]);
const [guides, setGuides] = useState([]);
const [selectedGuide, setSelectedGuide] = useState('');
const [loading, setLoading] = useState(true);
const [stats, setStats] = useState({ totalDays: 0, totalGuides: 0 });
useEffect(() => {
loadGuides();
}, []);
useEffect(() => {
loadWorkingDays();
}, [currentDate, selectedGuide]);
const loadGuides = async () => {
try {
const response = await fetch('/api/guides');
const data = await response.json();
setGuides(data.success ? data.data : data);
} catch (error) {
console.error('Error loading guides:', error);
}
};
const loadWorkingDays = async () => {
setLoading(true);
try {
const month = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}`;
const url = selectedGuide
? `/api/guide-working-days?month=${month}&guide_id=${selectedGuide}`
: `/api/guide-working-days?month=${month}`;
const response = await fetch(url);
const data = await response.json();
setWorkingDays(data);
// Подсчет статистики
const uniqueGuides = new Set(data.map(d => d.guide_id));
setStats({
totalDays: data.length,
totalGuides: uniqueGuides.size
});
} catch (error) {
console.error('Error loading working days:', error);
}
setLoading(false);
};
const getDaysInMonth = (date) => {
const year = date.getFullYear();
const month = date.getMonth();
const daysInMonth = new Date(year, month + 1, 0).getDate();
const firstDayOfWeek = new Date(year, month, 1).getDay();
const days = [];
// Добавляем пустые дни в начале
for (let i = 0; i < (firstDayOfWeek === 0 ? 6 : firstDayOfWeek - 1); i++) {
days.push(null);
}
// Добавляем дни месяца
for (let day = 1; day <= daysInMonth; day++) {
days.push(day);
}
return days;
};
const getWorkingDaysForDate = (day) => {
if (!day) return [];
const dateStr = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return workingDays.filter(wd => wd.work_date === dateStr);
};
const getGuideById = (id) => {
return guides.find(g => g.id === id);
};
const changeMonth = (delta) => {
const newDate = new Date(currentDate);
newDate.setMonth(newDate.getMonth() + delta);
setCurrentDate(newDate);
};
const monthNames = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
const weekDays = ['ПН', 'ВТ', 'СР', 'ЧТ', 'ПТ', 'СБ', 'ВС'];
if (loading && workingDays.length === 0) {
return (
<div style={{ padding: '20px', textAlign: 'center' }}>
Загрузка календаря...
</div>
);
}
return (
<div style={{ padding: '20px', fontFamily: 'system-ui' }}>
{/* Заголовок и статистика */}
<div style={{ marginBottom: '20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h2>📅 Календарь рабочих дней гидов</h2>
<div style={{ display: 'flex', gap: '20px', fontSize: '14px' }}>
<div style={{ padding: '8px 12px', background: '#e3f2fd', borderRadius: '6px' }}>
<strong>{stats.totalDays}</strong> рабочих дней
</div>
<div style={{ padding: '8px 12px', background: '#f3e5f5', borderRadius: '6px' }}>
<strong>{stats.totalGuides}</strong> активных гидов
</div>
</div>
</div>
{/* Фильтр по гиду */}
<div style={{ marginBottom: '20px', display: 'flex', alignItems: 'center', gap: '15px' }}>
<label style={{ fontWeight: 'bold' }}>Фильтр по гиду:</label>
<select
value={selectedGuide}
onChange={(e) => setSelectedGuide(e.target.value)}
style={{
padding: '8px 12px',
borderRadius: '6px',
border: '1px solid #ddd',
minWidth: '200px',
fontSize: '14px'
}}
>
<option value="">Все гиды</option>
{guides.map(guide => (
<option key={guide.id} value={guide.id}>
{guide.name} ({guide.specialization})
</option>
))}
</select>
{selectedGuide && (
<button
onClick={() => setSelectedGuide('')}
style={{
padding: '6px 12px',
background: '#ff5722',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px'
}}
>
Очистить
</button>
)}
</div>
{/* Навигация по месяцам */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '20px',
padding: '15px',
background: '#f8f9fa',
borderRadius: '8px'
}}>
<button
onClick={() => changeMonth(-1)}
style={{
padding: '10px 20px',
border: '1px solid #ddd',
borderRadius: '6px',
background: 'white',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold'
}}
>
Предыдущий
</button>
<h3 style={{ margin: 0, color: '#333' }}>
{monthNames[currentDate.getMonth()]} {currentDate.getFullYear()}
</h3>
<button
onClick={() => changeMonth(1)}
style={{
padding: '10px 20px',
border: '1px solid #ddd',
borderRadius: '6px',
background: 'white',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold'
}}
>
Следующий
</button>
</div>
{/* Календарная сетка */}
<div style={{
background: 'white',
borderRadius: '8px',
overflow: 'hidden',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}>
{/* Заголовки дней недели */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
{weekDays.map(day => (
<div key={day} style={{
padding: '15px',
textAlign: 'center',
fontWeight: 'bold',
background: '#f5f5f5',
borderBottom: '1px solid #ddd',
color: '#333'
}}>
{day}
</div>
))}
</div>
{/* Дни месяца */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)' }}>
{getDaysInMonth(currentDate).map((day, index) => {
const dayWorkingData = getWorkingDaysForDate(day);
const hasData = dayWorkingData.length > 0;
return (
<div
key={index}
style={{
padding: '10px',
minHeight: '120px',
border: '1px solid #e0e0e0',
background: day ? (hasData ? '#e8f5e8' : 'white') : '#f9f9f9',
color: day ? '#333' : '#ccc',
position: 'relative'
}}
>
{day && (
<>
<div style={{
fontWeight: 'bold',
marginBottom: '8px',
color: hasData ? '#2e7d32' : '#666'
}}>
{day}
</div>
{dayWorkingData.map((workDay, idx) => {
const guide = getGuideById(workDay.guide_id);
return (
<div
key={idx}
style={{
fontSize: '11px',
padding: '4px 6px',
margin: '2px 0',
background: guide?.specialization === 'city' ? '#bbdefb' :
guide?.specialization === 'mountain' ? '#c8e6c9' :
guide?.specialization === 'fishing' ? '#ffcdd2' : '#f0f0f0',
borderRadius: '4px',
color: '#333',
lineHeight: '1.2'
}}
title={workDay.notes}
>
<div style={{ fontWeight: 'bold' }}>
{guide?.name || `Гид #${workDay.guide_id}`}
</div>
{workDay.notes && (
<div style={{ opacity: 0.8, marginTop: '2px' }}>
{workDay.notes.length > 20 ? workDay.notes.substring(0, 20) + '...' : workDay.notes}
</div>
)}
</div>
);
})}
</>
)}
</div>
);
})}
</div>
</div>
{/* Легенда */}
<div style={{
marginTop: '20px',
display: 'flex',
gap: '20px',
fontSize: '14px',
flexWrap: 'wrap'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '20px', background: '#bbdefb', borderRadius: '4px' }}></div>
<span>Городские туры</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '20px', background: '#c8e6c9', borderRadius: '4px' }}></div>
<span>Горные туры</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '20px', background: '#ffcdd2', borderRadius: '4px' }}></div>
<span>Морская рыбалка</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<div style={{ width: '20px', height: '20px', background: '#e8f5e8', borderRadius: '4px' }}></div>
<span>Рабочий день</span>
</div>
</div>
{/* Быстрые действия */}
<div style={{
marginTop: '20px',
padding: '15px',
background: '#f8f9fa',
borderRadius: '8px'
}}>
<h4 style={{ margin: '0 0 10px 0' }}>Быстрые действия:</h4>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
<button
onClick={() => window.open('/admin/calendar-view', '_blank')}
style={{
padding: '8px 16px',
background: '#2196f3',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
📅 Полный календарь
</button>
<button
onClick={() => window.open('/admin/schedule-manager', '_blank')}
style={{
padding: '8px 16px',
background: '#4caf50',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
Планировщик смен
</button>
<button
onClick={() => loadWorkingDays()}
style={{
padding: '8px 16px',
background: '#ff9800',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px'
}}
>
🔄 Обновить
</button>
</div>
</div>
</div>
);
};
export default GuideCalendarView;

View File

@@ -0,0 +1,633 @@
/**
* GuideCalendarWidget - Переиспользуемый компонент календаря гидов
* Может использоваться на фронтенде для бронирования и в админке
*/
class GuideCalendarWidget {
constructor(options = {}) {
this.container = options.container || document.body;
this.mode = options.mode || 'booking'; // 'booking', 'admin', 'readonly'
this.onDateSelect = options.onDateSelect || null;
this.onGuideSelect = options.onGuideSelect || null;
this.showGuideFilter = options.showGuideFilter !== false;
this.showLegend = options.showLegend !== false;
this.compact = options.compact || false;
this.selectedDate = options.selectedDate || null;
this.selectedGuideId = options.selectedGuideId || null;
this.currentDate = new Date();
this.guides = [];
this.schedules = [];
this.holidays = [];
this.bookings = [];
this.selectedGuides = new Set();
this.init();
}
async init() {
this.render();
await this.loadData();
this.renderGuidesFilter();
this.renderCalendar();
this.updateMonthDisplay();
this.bindEvents();
}
render() {
const compactClass = this.compact ? 'calendar-compact' : '';
const modeClass = `calendar-mode-${this.mode}`;
this.container.innerHTML = `
<div class="guide-calendar-widget ${compactClass} ${modeClass}">
<div class="calendar-header">
<div class="calendar-navigation">
<button class="nav-button" data-action="prev-month"></button>
<span class="current-date" id="currentDate-${this.getId()}"></span>
<button class="nav-button" data-action="next-month"></button>
</div>
${this.showGuideFilter ? `
<div class="guides-filter">
<span class="filter-label">Гиды:</span>
<div class="guides-filter-container" id="guidesFilter-${this.getId()}"></div>
</div>
` : ''}
</div>
<div class="calendar-grid" id="calendarGrid-${this.getId()}"></div>
${this.showLegend ? `
<div class="calendar-legend">
<div class="legend-item">
<div class="legend-color legend-working"></div>
<span>Доступен</span>
</div>
<div class="legend-item">
<div class="legend-color legend-holiday"></div>
<span>Выходной</span>
</div>
<div class="legend-item">
<div class="legend-color legend-busy"></div>
<span>Занят</span>
</div>
${this.mode === 'booking' ? `
<div class="legend-item">
<div class="legend-color legend-selected"></div>
<span>Выбранная дата</span>
</div>
` : ''}
</div>
` : ''}
</div>
`;
this.injectStyles();
}
getId() {
if (!this._id) {
this._id = 'calendar-' + Math.random().toString(36).substr(2, 9);
}
return this._id;
}
injectStyles() {
if (document.getElementById('guide-calendar-styles')) return;
const styles = `
<style id="guide-calendar-styles">
.guide-calendar-widget {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 100%;
}
.calendar-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
flex-wrap: wrap;
gap: 15px;
}
.calendar-navigation {
display: flex;
align-items: center;
gap: 15px;
}
.nav-button {
background: #007bff;
color: white;
border: none;
padding: 8px 12px;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
}
.nav-button:hover {
background: #0056b3;
}
.current-date {
font-size: 18px;
font-weight: 600;
color: #343a40;
min-width: 150px;
text-align: center;
}
.guides-filter {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.filter-label {
font-weight: 600;
color: #495057;
}
.guides-filter-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.guide-checkbox {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 10px;
border: 2px solid #dee2e6;
border-radius: 15px;
cursor: pointer;
transition: all 0.3s;
background: white;
font-size: 12px;
}
.guide-checkbox:hover {
border-color: #007bff;
background: #e3f2fd;
}
.guide-checkbox.checked {
background: #007bff;
color: white;
border-color: #007bff;
}
.guide-checkbox input[type="checkbox"] {
display: none;
}
.calendar-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
background: #dee2e6;
border-radius: 8px;
overflow: hidden;
}
.calendar-day {
background: white;
min-height: 80px;
padding: 6px;
position: relative;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.calendar-day:hover {
background: #f0f8ff;
}
.calendar-day.other-month {
background: #f8f9fa;
color: #6c757d;
}
.calendar-day.today {
background: #fff3cd;
border-color: #ffeaa7;
}
.calendar-day.selected {
background: #e3f2fd;
border-color: #007bff;
box-shadow: inset 0 0 0 2px #007bff;
}
.day-number {
font-weight: 600;
font-size: 14px;
margin-bottom: 6px;
}
.day-header {
background: #343a40;
color: white;
padding: 10px 6px;
text-align: center;
font-weight: 600;
font-size: 12px;
}
.guide-status {
display: flex;
flex-wrap: wrap;
gap: 2px;
}
.guide-badge {
padding: 1px 4px;
border-radius: 8px;
font-size: 9px;
font-weight: 500;
white-space: nowrap;
}
.guide-badge.working {
background: #d4edda;
color: #155724;
}
.guide-badge.holiday {
background: #f8d7da;
color: #721c24;
}
.guide-badge.busy {
background: #fff3cd;
color: #856404;
}
.calendar-legend {
display: flex;
justify-content: center;
gap: 20px;
margin-top: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: 8px;
font-size: 12px;
flex-wrap: wrap;
}
.legend-item {
display: flex;
align-items: center;
gap: 5px;
}
.legend-color {
width: 10px;
height: 10px;
border-radius: 2px;
}
.legend-working { background: #d4edda; }
.legend-holiday { background: #f8d7da; }
.legend-busy { background: #fff3cd; }
.legend-selected { background: #e3f2fd; border: 1px solid #007bff; }
/* Компактный режим */
.calendar-compact .calendar-day {
min-height: 60px;
padding: 4px;
}
.calendar-compact .day-number {
font-size: 12px;
margin-bottom: 4px;
}
.calendar-compact .guide-badge {
font-size: 8px;
padding: 1px 3px;
}
/* Режим бронирования */
.calendar-mode-booking .calendar-day:hover {
background: #e3f2fd;
border-color: #007bff;
}
/* Режим только для чтения */
.calendar-mode-readonly .calendar-day {
cursor: default;
}
.calendar-mode-readonly .calendar-day:hover {
background: white;
}
.error {
background: #f8d7da;
color: #721c24;
padding: 15px;
border-radius: 5px;
text-align: center;
margin: 20px 0;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
bindEvents() {
this.container.addEventListener('click', (e) => {
if (e.target.matches('[data-action="prev-month"]')) {
this.changeMonth(-1);
} else if (e.target.matches('[data-action="next-month"]')) {
this.changeMonth(1);
} else if (e.target.closest('.guide-checkbox')) {
const checkbox = e.target.closest('.guide-checkbox');
const guideId = parseInt(checkbox.dataset.guideId);
this.toggleGuide(guideId);
} else if (e.target.closest('.calendar-day')) {
const dayEl = e.target.closest('.calendar-day');
const dateStr = dayEl.dataset.date;
if (dateStr && this.mode === 'booking') {
this.selectDate(dateStr);
}
}
});
}
async loadData() {
try {
// Загружаем гидов
const guidesResponse = await fetch('/api/guides');
const guidesData = await guidesResponse.json();
this.guides = Array.isArray(guidesData) ? guidesData : (guidesData.data || []);
// Загружаем остальные данные параллельно
const [schedulesRes, holidaysRes, bookingsRes] = await Promise.all([
fetch('/api/guide-schedules'),
fetch('/api/holidays'),
fetch('/api/bookings')
]);
const schedulesData = await schedulesRes.json();
const holidaysData = await holidaysRes.json();
const bookingsData = await bookingsRes.json();
this.schedules = Array.isArray(schedulesData) ? schedulesData : (schedulesData.data || []);
this.holidays = Array.isArray(holidaysData) ? holidaysData : (holidaysData.data || []);
this.bookings = Array.isArray(bookingsData) ? bookingsData : (bookingsData.data || []);
// Инициализируем выбранных гидов
if (this.guides && this.guides.length > 0) {
if (this.selectedGuideId) {
this.selectedGuides.add(this.selectedGuideId);
} else {
this.guides.forEach(guide => this.selectedGuides.add(guide.id));
}
}
} catch (error) {
console.error('Ошибка загрузки данных календаря:', error);
this.showError('Ошибка загрузки данных календаря');
}
}
showError(message) {
const gridEl = this.container.querySelector(`#calendarGrid-${this.getId()}`);
if (gridEl) {
gridEl.innerHTML = `<div class="error">${message}</div>`;
}
}
renderGuidesFilter() {
if (!this.showGuideFilter) return;
const filterContainer = this.container.querySelector(`#guidesFilter-${this.getId()}`);
if (!filterContainer) return;
filterContainer.innerHTML = '';
if (!this.guides || !Array.isArray(this.guides) || this.guides.length === 0) {
filterContainer.innerHTML = '<div class="error">Нет доступных гидов</div>';
return;
}
this.guides.forEach(guide => {
const checkbox = document.createElement('label');
checkbox.className = 'guide-checkbox';
checkbox.dataset.guideId = guide.id;
if (this.selectedGuides.has(guide.id)) {
checkbox.classList.add('checked');
}
checkbox.innerHTML = `
<input type="checkbox" ${this.selectedGuides.has(guide.id) ? 'checked' : ''}>
<span>${guide.name.split(' ')[0]}</span>
`;
filterContainer.appendChild(checkbox);
});
}
toggleGuide(guideId) {
if (this.selectedGuides.has(guideId)) {
this.selectedGuides.delete(guideId);
} else {
this.selectedGuides.add(guideId);
}
this.renderGuidesFilter();
this.renderCalendar();
if (this.onGuideSelect) {
this.onGuideSelect(Array.from(this.selectedGuides));
}
}
selectDate(dateStr) {
this.selectedDate = dateStr;
this.renderCalendar();
if (this.onDateSelect) {
this.onDateSelect(dateStr);
}
}
renderCalendar() {
const grid = this.container.querySelector(`#calendarGrid-${this.getId()}`);
if (!grid) return;
grid.innerHTML = '';
// Заголовки дней недели
const dayHeaders = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
dayHeaders.forEach(day => {
const headerDiv = document.createElement('div');
headerDiv.className = 'day-header';
headerDiv.textContent = day;
grid.appendChild(headerDiv);
});
const year = this.currentDate.getFullYear();
const month = this.currentDate.getMonth();
// Первый день месяца
const firstDay = new Date(year, month, 1);
// Первый понедельник на календаре
const startDate = new Date(firstDay);
const dayOfWeek = firstDay.getDay();
const mondayOffset = dayOfWeek === 0 ? -6 : -(dayOfWeek - 1);
startDate.setDate(firstDay.getDate() + mondayOffset);
// Генерируем 6 недель
for (let week = 0; week < 6; week++) {
for (let day = 0; day < 7; day++) {
const currentDay = new Date(startDate);
currentDay.setDate(startDate.getDate() + week * 7 + day);
const dayDiv = document.createElement('div');
dayDiv.className = 'calendar-day';
dayDiv.dataset.date = this.formatDate(currentDay);
if (currentDay.getMonth() !== month) {
dayDiv.classList.add('other-month');
}
if (this.isToday(currentDay)) {
dayDiv.classList.add('today');
}
if (this.selectedDate === this.formatDate(currentDay)) {
dayDiv.classList.add('selected');
}
dayDiv.innerHTML = this.renderDay(currentDay);
grid.appendChild(dayDiv);
}
}
}
renderDay(date) {
const dayNumber = date.getDate();
const dateStr = this.formatDate(date);
let guideStatusHtml = '';
// Получаем статусы выбранных гидов для этого дня
this.guides.forEach(guide => {
if (!this.selectedGuides.has(guide.id)) return;
const status = this.getGuideStatus(guide.id, dateStr);
const statusClass = status === 'holiday' ? 'holiday' :
status === 'busy' ? 'busy' : 'working';
guideStatusHtml += `<div class="guide-badge ${statusClass}" title="${guide.name} - ${this.getStatusText(status)}">${guide.name.split(' ')[0]}</div>`;
});
return `
<div class="day-number">${dayNumber}</div>
<div class="guide-status">${guideStatusHtml}</div>
`;
}
getStatusText(status) {
const statusMap = {
'working': 'Доступен',
'holiday': 'Выходной',
'busy': 'Занят'
};
return statusMap[status] || 'Неизвестно';
}
getGuideStatus(guideId, dateStr) {
// Проверяем выходные дни
const holiday = this.holidays.find(h =>
h.guide_id === guideId && h.holiday_date === dateStr
);
if (holiday) return 'holiday';
// Проверяем бронирования
const booking = this.bookings.find(b =>
b.guide_id === guideId &&
this.formatDate(new Date(b.preferred_date)) === dateStr
);
if (booking) return 'busy';
return 'working';
}
formatDate(date) {
return date.toISOString().split('T')[0];
}
isToday(date) {
const today = new Date();
return date.toDateString() === today.toDateString();
}
updateMonthDisplay() {
const monthDisplay = this.container.querySelector(`#currentDate-${this.getId()}`);
if (!monthDisplay) return;
const monthNames = [
'Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь',
'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'
];
monthDisplay.textContent = `${monthNames[this.currentDate.getMonth()]} ${this.currentDate.getFullYear()}`;
}
changeMonth(delta) {
this.currentDate.setMonth(this.currentDate.getMonth() + delta);
this.renderCalendar();
this.updateMonthDisplay();
}
// Публичные методы для внешнего управления
setSelectedDate(dateStr) {
this.selectedDate = dateStr;
this.renderCalendar();
}
setSelectedGuide(guideId) {
this.selectedGuides.clear();
this.selectedGuides.add(guideId);
this.renderGuidesFilter();
this.renderCalendar();
}
getAvailableGuides(dateStr) {
return this.guides.filter(guide =>
this.getGuideStatus(guide.id, dateStr) === 'working'
);
}
refresh() {
this.loadData().then(() => {
this.renderGuidesFilter();
this.renderCalendar();
});
}
}
// Экспортируем для использования в других файлах
if (typeof module !== 'undefined' && module.exports) {
module.exports = GuideCalendarWidget;
}
// Глобальная доступность в браузере
if (typeof window !== 'undefined') {
window.GuideCalendarWidget = GuideCalendarWidget;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,23 @@
/* Korea Tourism Agency - Main Styles */
/* CSS Variables */
/* CSS Variables - могут быть переопределены через настройки сайта */
:root {
/* Основные цвета */
--primary-color: #2563eb;
--primary-light: #3b82f6;
--primary-dark: #1d4ed8;
--secondary-color: #dc2626;
--accent-color: #059669;
--success-color: #059669;
--warning-color: #d97706;
--info-color: #0891b2;
--light-color: #f8fafc;
--dark-color: #0f172a;
--text-color: #334155;
--background-color: #ffffff;
--card-background: #f8fafc;
/* Серые тона */
--gray-100: #f1f5f9;
--gray-200: #e2e8f0;
--gray-300: #cbd5e1;
@@ -20,31 +27,56 @@
--gray-700: #334155;
--gray-800: #1e293b;
--gray-900: #0f172a;
/* Корейские цвета */
--korean-red: #c41e3a;
--korean-blue: #003478;
--font-korean: 'Noto Sans KR', 'Malgun Gothic', '맑은 고딕', sans-serif;
--font-display: 'Playfair Display', serif;
/* Типографика */
--font-family-primary: 'Noto Sans KR', 'Malgun Gothic', '맑은 고딕', sans-serif;
--font-family-display: 'Playfair Display', serif;
--font-size-base: 16px;
--line-height-base: 1.7;
/* Эффекты */
--hero-overlay-opacity: 0.8;
--hero-overlay-color: #2563eb;
--card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--border-radius: 8px;
--blur-effect: 10px;
--animation-duration: 0.3s;
/* Тени */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
--shadow: var(--card-shadow);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Макет */
--hero-height-desktop: 70vh;
--hero-height-mobile: 50vh;
--compact-hero-height: 25vh;
--container-max-width: 1200px;
--navbar-height: 76px;
}
/* Base Styles */
body {
font-family: var(--font-korean);
line-height: 1.7;
color: var(--gray-700);
padding-top: 76px; /* Account for fixed navbar */
font-family: var(--font-family-primary);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
color: var(--text-color);
background-color: var(--background-color);
padding-top: var(--navbar-height);
}
.font-korean {
font-family: var(--font-korean);
font-family: var(--font-family-primary);
}
.font-display {
font-family: var(--font-display);
font-family: var(--font-family-display);
}
/* Custom Bootstrap Overrides */
@@ -52,7 +84,7 @@ body {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
border: none;
box-shadow: var(--shadow-md);
transition: all 0.3s ease;
transition: all var(--animation-duration) ease;
}
.btn-primary:hover {
@@ -63,8 +95,8 @@ body {
.text-gradient {
background: linear-gradient(135deg, var(--korean-red) 0%, var(--korean-blue) 100%);
background-clip: text;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
@@ -74,14 +106,15 @@ body {
/* Navigation Styles */
.navbar {
background: rgba(37, 99, 235, 0.95) !important;
backdrop-filter: blur(10px);
background: rgba(37, 99, 235, var(--hero-overlay-opacity)) !important;
-webkit-backdrop-filter: blur(var(--blur-effect));
backdrop-filter: blur(var(--blur-effect));
box-shadow: var(--shadow-md);
transition: all 0.3s ease;
transition: all var(--animation-duration) ease;
}
.navbar-brand {
font-family: var(--font-display);
font-family: var(--font-family-display);
font-weight: 700;
font-size: 1.5rem;
}
@@ -89,7 +122,7 @@ body {
.nav-link {
font-weight: 500;
position: relative;
transition: all 0.3s ease;
transition: all var(--animation-duration) ease;
}
.nav-link:hover {
@@ -111,21 +144,21 @@ body {
/* Hero Section */
.hero-section {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%);
min-height: 70vh;
min-height: var(--hero-height-desktop);
position: relative;
overflow: hidden;
}
/* Compact Hero Section for other pages */
.hero-section.compact {
min-height: 25vh;
min-height: var(--compact-hero-height);
padding: 3rem 0;
}
/* Mobile optimization for hero sections */
@media (max-width: 768px) {
.hero-section {
min-height: 50vh;
min-height: var(--hero-height-mobile);
padding: 2rem 0;
}
@@ -154,9 +187,9 @@ body {
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.8) 0%, rgba(0, 52, 120, 0.9) 100%);
background: linear-gradient(135deg, rgba(37, 99, 235, var(--hero-overlay-opacity)) 0%, rgba(0, 52, 120, 0.9) 100%);
z-index: 2;
pointer-events: none; /* Позволяет кликам проходить через overlay */
pointer-events: none;
}
.hero-section .container {

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

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

View File

@@ -0,0 +1,543 @@
<!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;
font-size: 14px;
background: #fff;
color: #333;
}
.container {
max-width: 600px;
margin: 20px auto;
padding: 20px;
}
.tabs {
display: flex;
border-bottom: 2px solid #eee;
margin-bottom: 20px;
}
.tab {
padding: 10px 20px;
cursor: pointer;
border: none;
background: none;
font-size: 14px;
color: #666;
border-bottom: 2px solid transparent;
transition: all 0.2s;
}
.tab.active {
color: #007bff;
border-bottom-color: #007bff;
}
.tab-content {
display: none;
min-height: 300px;
}
.tab-content.active {
display: block;
}
/* Загрузка */
.upload-zone {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px;
text-align: center;
background: #fafafa;
transition: all 0.2s;
cursor: pointer;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: #007bff;
background: #f0f8ff;
}
.upload-icon {
font-size: 48px;
color: #ccc;
margin-bottom: 10px;
}
.file-input {
display: none;
}
/* Галерея */
.gallery {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
}
.gallery-item {
aspect-ratio: 1;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: all 0.2s;
}
.gallery-item:hover {
border-color: #ddd;
}
.gallery-item.selected {
border-color: #007bff;
box-shadow: 0 0 10px rgba(0,123,255,0.3);
}
.gallery-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
/* URL ввод */
.url-input {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 6px;
font-size: 14px;
margin-bottom: 10px;
}
/* Превью */
.preview {
text-align: center;
margin: 20px 0;
}
.preview img {
max-width: 200px;
max-height: 150px;
object-fit: cover;
border-radius: 6px;
border: 1px solid #ddd;
}
/* Кнопки */
.actions {
display: flex;
gap: 10px;
padding-top: 20px;
border-top: 1px solid #eee;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.btn-primary {
background: #007bff;
color: white;
border-color: #007bff;
}
.btn:hover {
background: #f0f0f0;
}
.btn-primary:hover {
background: #0056b3;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.error {
color: #dc3545;
background: #f8d7da;
border: 1px solid #f5c6cb;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
}
</style>
</head>
<body>
<div class="container">
<div class="tabs">
<button class="tab active" onclick="switchTab('upload')">📤 Загрузить</button>
<button class="tab" onclick="switchTab('gallery')">🖼️ Галерея</button>
<button class="tab" onclick="switchTab('url')">🔗 По ссылке</button>
</div>
<!-- Загрузка -->
<div id="upload-tab" class="tab-content active">
<div class="upload-zone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📁</div>
<p><strong>Выберите файл</strong> или перетащите сюда</p>
<p style="color: #666; font-size: 12px;">JPG, PNG, GIF (макс. 5МБ)</p>
</div>
<input type="file" id="fileInput" class="file-input" accept="image/*">
</div>
<!-- Галерея -->
<div id="gallery-tab" class="tab-content">
<div id="galleryContent" class="loading">Загрузка галереи...</div>
</div>
<!-- URL -->
<div id="url-tab" class="tab-content">
<input type="url" id="urlInput" class="url-input" placeholder="Вставьте ссылку на изображение">
<button class="btn" onclick="previewFromUrl()">Предварительный просмотр</button>
</div>
<!-- Превью -->
<div id="preview" class="preview" style="display: none;">
<img id="previewImage" src="" alt="Превью">
</div>
<!-- Действия -->
<div class="actions">
<button class="btn" onclick="closeEditor()">Отмена</button>
<button class="btn btn-primary" onclick="selectImage()">Выбрать</button>
</div>
</div>
<script>
let selectedImageUrl = '';
let currentField = null;
// Получаем параметры из URL
const urlParams = new URLSearchParams(window.location.search);
const fieldName = urlParams.get('field') || 'image';
const currentValue = urlParams.get('current') || '';
// Настройка обработчиков загрузки
function setupUploadHandlers() {
// Обработка загрузки файла
const fileInput = document.getElementById('fileInput');
if (fileInput) {
fileInput.removeEventListener('change', handleFileSelect); // Убираем предыдущий
fileInput.addEventListener('change', handleFileSelect);
}
// Drag & Drop
const uploadZone = document.querySelector('.upload-zone');
if (uploadZone) {
// Убираем предыдущие обработчики
uploadZone.removeEventListener('dragover', handleDragOver);
uploadZone.removeEventListener('dragleave', handleDragLeave);
uploadZone.removeEventListener('drop', handleDrop);
// Добавляем новые
uploadZone.addEventListener('dragover', handleDragOver);
uploadZone.addEventListener('dragleave', handleDragLeave);
uploadZone.addEventListener('drop', handleDrop);
}
}
// Обработчики событий
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) {
if (file.size > 5 * 1024 * 1024) {
showError('Файл слишком большой. Максимум 5МБ.');
return;
}
uploadFile(file);
}
}
function handleDragOver(e) {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.add('dragover');
}
function handleDragLeave(e) {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove('dragover');
}
function handleDrop(e) {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
const file = files[0];
if (file.type.startsWith('image/')) {
if (file.size > 5 * 1024 * 1024) {
showError('Файл слишком большой. Максимум 5МБ.');
return;
}
uploadFile(file);
} else {
showError('Пожалуйста, выберите изображение (JPG, PNG, GIF)');
}
}
}
// Показываем текущее изображение если есть
if (currentValue) {
showPreview(currentValue);
selectedImageUrl = currentValue;
}
// Инициализируем обработчики загрузки
setupUploadHandlers();
// Переключение табов
function switchTab(tabName) {
// Убираем активные классы
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// Добавляем активные классы
event.target.classList.add('active');
document.getElementById(tabName + '-tab').classList.add('active');
// Загружаем галерею при первом открытии
if (tabName === 'gallery') {
loadGallery();
}
}
// Загрузка файла на сервер
async function uploadFile(file) {
const formData = new FormData();
formData.append('image', file);
formData.append('folder', getFolderName());
try {
showLoading('Загрузка изображения...');
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const result = await response.json();
console.log('Upload result:', result);
if (result.success) {
selectedImageUrl = result.url;
showPreview(result.url);
hideLoading();
showSuccess('Изображение успешно загружено!');
} else {
throw new Error(result.error || 'Ошибка загрузки');
}
} catch (error) {
console.error('Upload error:', error);
hideLoading();
showError('Ошибка загрузки: ' + error.message);
}
}
// Загрузка галереи
async function loadGallery() {
const galleryContent = document.getElementById('galleryContent');
galleryContent.innerHTML = '<div class="loading">Загрузка галереи...</div>';
try {
const response = await fetch('/api/images/gallery');
if (!response.ok) {
throw new Error(`HTTP Error: ${response.status}`);
}
const result = await response.json();
console.log('Gallery result:', result);
if (result.success) {
const gallery = document.createElement('div');
gallery.className = 'gallery';
const images = result.data || result.images || [];
if (images.length === 0) {
gallery.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: #666;">Нет изображений в галерее</p>';
} else {
images.forEach(image => {
const item = document.createElement('div');
item.className = 'gallery-item';
item.innerHTML = `<img src="${image.path}" alt="${image.name}" onerror="this.parentNode.style.display='none'">`;
item.onclick = () => selectFromGallery(image.path, item);
gallery.appendChild(item);
});
}
galleryContent.innerHTML = '';
galleryContent.appendChild(gallery);
} else {
throw new Error(result.error || 'Ошибка получения галереи');
}
} catch (error) {
console.error('Gallery error:', error);
galleryContent.innerHTML = `<div class="error">Ошибка загрузки галереи: ${error.message}</div>`;
}
}
// Выбор из галереи
function selectFromGallery(url, element) {
// Убираем выделение с других элементов
document.querySelectorAll('.gallery-item').forEach(item => {
item.classList.remove('selected');
});
// Выделяем выбранный элемент
element.classList.add('selected');
selectedImageUrl = url;
showPreview(url);
}
// Предпросмотр по URL
function previewFromUrl() {
const url = document.getElementById('urlInput').value.trim();
if (url) {
// Проверяем, что это валидный URL
try {
new URL(url);
selectedImageUrl = url;
showPreview(url);
showSuccess('URL изображения установлен!');
} catch (e) {
showError('Пожалуйста, введите корректный URL');
}
} else {
showError('Пожалуйста, введите URL изображения');
}
}
// Показать превью
function showPreview(url) {
const preview = document.getElementById('preview');
const previewImage = document.getElementById('previewImage');
previewImage.src = url;
preview.style.display = 'block';
previewImage.onload = function() {
console.log('Image loaded successfully:', url);
};
previewImage.onerror = function() {
showError('Не удалось загрузить изображение по указанному URL');
preview.style.display = 'none';
selectedImageUrl = '';
};
}
// Выбрать изображение
function selectImage() {
if (selectedImageUrl) {
// Отправляем выбранный URL родительскому окну
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'imageSelected',
url: selectedImageUrl,
field: fieldName
}, '*');
}
closeEditor();
} else {
showError('Выберите изображение');
}
}
// Закрыть редактор
function closeEditor() {
if (window.parent && window.parent !== window) {
window.parent.postMessage({
type: 'editorClosed'
}, '*');
}
}
// Получить имя папки из поля
function getFolderName() {
if (fieldName.includes('route') || fieldName.includes('tour')) return 'routes';
if (fieldName.includes('guide')) return 'guides';
if (fieldName.includes('article')) return 'articles';
return 'general';
}
// Показать ошибку
function showError(message) {
hideLoading();
removeMessages();
const errorDiv = document.createElement('div');
errorDiv.className = 'error';
errorDiv.textContent = message;
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.actions'));
setTimeout(() => errorDiv.remove(), 5000);
}
// Показать успех
function showSuccess(message) {
removeMessages();
const successDiv = document.createElement('div');
successDiv.className = 'success';
successDiv.style.cssText = `
color: #155724;
background: #d4edda;
border: 1px solid #c3e6cb;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
`;
successDiv.textContent = message;
document.querySelector('.container').insertBefore(successDiv, document.querySelector('.actions'));
setTimeout(() => successDiv.remove(), 3000);
}
// Удалить все сообщения
function removeMessages() {
document.querySelectorAll('.error, .success').forEach(el => el.remove());
}
// Показать загрузку
function showLoading(message) {
const uploadTab = document.getElementById('upload-tab');
uploadTab.innerHTML = `<div class="loading">${message}</div>`;
}
// Скрыть загрузку
function hideLoading() {
const uploadTab = document.getElementById('upload-tab');
uploadTab.innerHTML = `
<div class="upload-zone" onclick="document.getElementById('fileInput').click()">
<div class="upload-icon">📁</div>
<p><strong>Выберите файл</strong> или перетащите сюда</p>
<p style="color: #666; font-size: 12px;">JPG, PNG, GIF (макс. 5МБ)</p>
</div>
<input type="file" id="fileInput" class="file-input" accept="image/*">
`;
// Переподключаем обработчики
setupUploadHandlers();
}
// Enter в поле URL
document.getElementById('urlInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
previewFromUrl();
}
});
</script>
</body>
</html>

448
public/image-editor.html Normal file
View File

@@ -0,0 +1,448 @@
<!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>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
.image-preview {
max-width: 200px;
max-height: 200px;
object-fit: cover;
border: 2px dashed #ddd;
border-radius: 8px;
}
.image-gallery {
max-height: 400px;
overflow-y: auto;
}
.gallery-item {
cursor: pointer;
transition: transform 0.2s;
}
.gallery-item:hover {
transform: scale(1.05);
}
.gallery-item.selected {
border: 3px solid #007bff;
border-radius: 8px;
}
.upload-area {
border: 2px dashed #ccc;
border-radius: 10px;
padding: 2rem;
text-align: center;
background: #f8f9fa;
transition: border-color 0.3s;
}
.upload-area:hover, .upload-area.drag-over {
border-color: #007bff;
background: #e3f2fd;
}
</style>
</head>
<body>
<div class="container-fluid py-4">
<div class="row">
<div class="col-md-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-images me-2"></i>
Редактор изображений
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="card-body">
<!-- Вкладки -->
<ul class="nav nav-tabs" id="imageEditorTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="upload-tab" data-bs-toggle="tab" data-bs-target="#upload" type="button">
<i class="fas fa-upload me-2"></i>Загрузить
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="gallery-tab" data-bs-toggle="tab" data-bs-target="#gallery" type="button">
<i class="fas fa-images me-2"></i>Галерея
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="url-tab" data-bs-toggle="tab" data-bs-target="#url" type="button">
<i class="fas fa-link me-2"></i>По ссылке
</button>
</li>
</ul>
<div class="tab-content" id="imageEditorTabsContent">
<!-- Вкладка загрузки -->
<div class="tab-pane fade show active" id="upload" role="tabpanel">
<div class="row mt-3">
<div class="col-md-6">
<div class="upload-area" id="uploadArea">
<i class="fas fa-cloud-upload-alt fa-3x text-muted mb-3"></i>
<p class="mb-2">Перетащите файлы сюда или нажмите для выбора</p>
<small class="text-muted">Поддерживаются: JPG, PNG, GIF (макс. 5МБ)</small>
<input type="file" id="fileInput" class="d-none" multiple accept="image/*">
</div>
<!-- Прогресс загрузки -->
<div id="uploadProgress" class="mt-3" style="display: none;">
<div class="progress">
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="text-center">
<h6>Предварительный просмотр</h6>
<img id="uploadPreview" class="image-preview" src="/images/placeholders/no-image.png" alt="Предварительный просмотр">
<div class="mt-2">
<small id="imageInfo" class="text-muted"></small>
</div>
</div>
</div>
</div>
</div>
<!-- Вкладка галереи -->
<div class="tab-pane fade" id="gallery" role="tabpanel">
<div class="row mt-3">
<div class="col-md-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h6>Загруженные изображения</h6>
<div class="btn-group">
<button type="button" class="btn btn-outline-secondary btn-sm" data-folder="routes">
<i class="fas fa-route me-1"></i>Маршруты
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-folder="guides">
<i class="fas fa-user-tie me-1"></i>Гиды
</button>
<button type="button" class="btn btn-outline-secondary btn-sm" data-folder="articles">
<i class="fas fa-newspaper me-1"></i>Статьи
</button>
<button type="button" class="btn btn-outline-secondary btn-sm active" data-folder="all">
<i class="fas fa-images me-1"></i>Все
</button>
</div>
</div>
<div class="image-gallery">
<div class="row" id="galleryImages">
<!-- Изображения будут загружены динамически -->
<div class="col-12 text-center py-4">
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
<p class="mt-2 text-muted">Загрузка галереи...</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Вкладка URL -->
<div class="tab-pane fade" id="url" role="tabpanel">
<div class="row mt-3">
<div class="col-md-8">
<label for="imageUrl" class="form-label">URL изображения</label>
<div class="input-group">
<input type="url" class="form-control" id="imageUrl" placeholder="https://example.com/image.jpg">
<button class="btn btn-primary" type="button" id="loadUrlImage">
<i class="fas fa-download me-1"></i>Загрузить
</button>
</div>
<small class="text-muted">Введите прямую ссылку на изображение</small>
</div>
<div class="col-md-4">
<div class="text-center">
<h6>Предварительный просмотр</h6>
<img id="urlPreview" class="image-preview" src="/images/placeholders/no-image.png" alt="Предварительный просмотр">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="d-flex justify-content-between">
<div>
<strong>Выбранное изображение:</strong>
<span id="selectedImagePath" class="text-muted ms-2">Не выбрано</span>
</div>
<div>
<button type="button" class="btn btn-secondary me-2" onclick="closeImageEditor()">
<i class="fas fa-times me-1"></i>Отмена
</button>
<button type="button" class="btn btn-primary" id="confirmSelection" disabled>
<i class="fas fa-check me-1"></i>Выбрать
</button>
</div>
</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>
<script>
let selectedImagePath = null;
let targetField = null;
// Инициализация редактора
function initImageEditor() {
setupUploadEvents();
setupGalleryEvents();
setupUrlEvents();
loadGallery('all');
}
// Настройка событий загрузки
function setupUploadEvents() {
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('drag-over');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('drag-over');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('drag-over');
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener('change', (e) => {
handleFiles(e.target.files);
});
}
// Обработка файлов
function handleFiles(files) {
if (files.length > 0) {
const file = files[0];
if (validateFile(file)) {
showPreview(file);
uploadFile(file);
}
}
}
// Валидация файла
function validateFile(file) {
const maxSize = 5 * 1024 * 1024; // 5MB
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
alert('Неподдерживаемый тип файла. Используйте JPG, PNG или GIF.');
return false;
}
if (file.size > maxSize) {
alert('Файл слишком большой. Максимальный размер: 5МБ.');
return false;
}
return true;
}
// Показ предварительного просмотра
function showPreview(file) {
const reader = new FileReader();
const preview = document.getElementById('uploadPreview');
const info = document.getElementById('imageInfo');
reader.onload = (e) => {
preview.src = e.target.result;
info.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
};
reader.readAsDataURL(file);
}
// Загрузка файла на сервер
async function uploadFile(file) {
const formData = new FormData();
formData.append('image', file);
const progress = document.getElementById('uploadProgress');
const progressBar = progress.querySelector('.progress-bar');
progress.style.display = 'block';
try {
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
selectedImagePath = result.data.path;
updateSelectedImage(selectedImagePath);
showSuccessMessage('Изображение успешно загружено!');
// Обновляем галерею
setTimeout(() => loadGallery('all'), 500);
} else {
alert('Ошибка загрузки: ' + result.error);
}
} catch (error) {
alert('Ошибка загрузки: ' + error.message);
} finally {
progress.style.display = 'none';
progressBar.style.width = '0%';
}
}
// Настройка событий галереи
function setupGalleryEvents() {
document.querySelectorAll('[data-folder]').forEach(btn => {
btn.addEventListener('click', (e) => {
document.querySelectorAll('[data-folder]').forEach(b => b.classList.remove('active'));
e.target.classList.add('active');
loadGallery(e.target.dataset.folder);
});
});
}
// Загрузка галереи
async function loadGallery(folder) {
const galleryContainer = document.getElementById('galleryImages');
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><i class="fas fa-spinner fa-spin fa-2x text-muted"></i><p class="mt-2 text-muted">Загрузка галереи...</p></div>';
try {
const response = await fetch(`/api/images/gallery?folder=${folder}`);
const result = await response.json();
if (result.success) {
renderGallery(result.data);
} else {
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><p class="text-muted">Ошибка загрузки галереи</p></div>';
}
} catch (error) {
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><p class="text-muted">Ошибка: ' + error.message + '</p></div>';
}
}
// Отображение галереи
function renderGallery(images) {
const galleryContainer = document.getElementById('galleryImages');
if (images.length === 0) {
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><p class="text-muted">Изображения не найдены</p></div>';
return;
}
galleryContainer.innerHTML = images.map(img => `
<div class="col-md-2 col-sm-3 col-4 mb-3">
<div class="gallery-item" data-path="${img.path}" onclick="selectGalleryImage('${img.path}')">
<img src="${img.path}" class="img-fluid rounded" alt="${img.name}" title="${img.name}">
<small class="d-block text-center text-muted mt-1">${img.name}</small>
</div>
</div>
`).join('');
}
// Выбор изображения из галереи
function selectGalleryImage(path) {
document.querySelectorAll('.gallery-item').forEach(item => {
item.classList.remove('selected');
});
document.querySelector(`[data-path="${path}"]`).classList.add('selected');
selectedImagePath = path;
updateSelectedImage(path);
}
// Настройка событий URL
function setupUrlEvents() {
const urlInput = document.getElementById('imageUrl');
const loadBtn = document.getElementById('loadUrlImage');
const urlPreview = document.getElementById('urlPreview');
loadBtn.addEventListener('click', () => {
const url = urlInput.value.trim();
if (url) {
urlPreview.src = url;
selectedImagePath = url;
updateSelectedImage(url);
}
});
urlInput.addEventListener('input', (e) => {
const url = e.target.value.trim();
if (url && isValidUrl(url)) {
urlPreview.src = url;
}
});
}
// Проверка валидности URL
function isValidUrl(string) {
try {
new URL(string);
return true;
} catch (_) {
return false;
}
}
// Обновление выбранного изображения
function updateSelectedImage(path) {
document.getElementById('selectedImagePath').textContent = path;
document.getElementById('confirmSelection').disabled = false;
}
// Подтверждение выбора
document.getElementById('confirmSelection').addEventListener('click', () => {
if (selectedImagePath && window.opener) {
// Передаем путь в родительское окно
window.opener.postMessage({
type: 'imageSelected',
path: selectedImagePath,
targetField: new URLSearchParams(window.location.search).get('field')
}, '*');
window.close();
}
});
// Закрытие редактора
function closeImageEditor() {
window.close();
}
// Показ сообщения об успехе
function showSuccessMessage(message) {
// Простое уведомление
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3';
alert.style.zIndex = '9999';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 3000);
}
// Инициализация при загрузке страницы
document.addEventListener('DOMContentLoaded', initImageEditor);
</script>
</body>
</html>

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

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

View File

@@ -0,0 +1,275 @@
<!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>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
.hero-compact {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 40px 0;
}
.feature-icon {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
font-size: 24px;
}
.feature-card {
border: none;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
transition: transform 0.3s ease;
}
.feature-card:hover {
transform: translateY(-5px);
}
.code-block {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 15px;
font-family: monospace;
margin: 15px 0;
}
.api-endpoint {
background: #e3f2fd;
padding: 10px;
border-radius: 4px;
margin: 10px 0;
}
.step-number {
width: 30px;
height: 30px;
border-radius: 50%;
background: #667eea;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
margin-right: 15px;
}
</style>
</head>
<body>
<!-- Навигация -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container">
<a class="navbar-brand" href="/">Korea Tourism</a>
<div class="navbar-nav ms-auto">
<a class="nav-link" href="/">Главная</a>
<a class="nav-link" href="/admin">Админ-панель</a>
<a class="nav-link" href="/test-editor">Тест редактора</a>
</div>
</div>
</nav>
<!-- Hero Section -->
<section class="hero-compact">
<div class="container text-center">
<h1 class="display-5 fw-bold mb-3">🖼️ Система управления изображениями</h1>
<p class="lead">Полнофункциональный редактор с возможностями обрезки, поворота и оптимизации</p>
</div>
</section>
<!-- Функции -->
<section class="py-5">
<div class="container">
<div class="row g-4">
<div class="col-md-4">
<div class="card feature-card h-100">
<div class="card-body text-center">
<div class="feature-icon bg-primary text-white">
<i class="fas fa-upload"></i>
</div>
<h5 class="card-title">Загрузка и обработка</h5>
<p class="card-text">Drag & Drop загрузка с автоматической оптимизацией и конвертацией в JPEG</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card feature-card h-100">
<div class="card-body text-center">
<div class="feature-icon bg-success text-white">
<i class="fas fa-crop"></i>
</div>
<h5 class="card-title">Редактирование</h5>
<p class="card-text">Обрезка, поворот на 90°, отражение горизонтально и вертикально</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card feature-card h-100">
<div class="card-body text-center">
<div class="feature-icon bg-info text-white">
<i class="fas fa-cogs"></i>
</div>
<h5 class="card-title">API интеграция</h5>
<p class="card-text">REST API для интеграции с любыми формами и компонентами</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Инструкции по использованию -->
<section class="py-5 bg-light">
<div class="container">
<h2 class="text-center mb-5">📋 Инструкции по использованию</h2>
<div class="row">
<div class="col-md-6">
<h4><i class="fas fa-user-cog me-2"></i>Через AdminJS</h4>
<div class="mb-3">
<span class="step-number">1</span>
Зайдите в <a href="/admin" target="_blank">админ-панель</a>
</div>
<div class="mb-3">
<span class="step-number">2</span>
Выберите "Туры" или "Гиды"
</div>
<div class="mb-3">
<span class="step-number">3</span>
В поле "Image URL" укажите путь к изображению
</div>
<div class="code-block">
Например: /uploads/routes/my-image.jpg
</div>
</div>
<div class="col-md-6">
<h4><i class="fas fa-code me-2"></i>Через JavaScript</h4>
<div class="mb-3">
<span class="step-number">1</span>
Подключите скрипт редактора
</div>
<div class="code-block">
&lt;script src="/js/image-editor.js"&gt;&lt;/script&gt;
</div>
<div class="mb-3">
<span class="step-number">2</span>
Откройте редактор
</div>
<div class="code-block">
window.openImageEditor({
targetFolder: 'routes', // routes, guides, articles
}).then(url => {
console.log('Сохранено:', url);
});
</div>
</div>
</div>
</div>
</section>
<!-- API Документация -->
<section class="py-5">
<div class="container">
<h2 class="text-center mb-5">🔌 API Эндпоинты</h2>
<div class="row g-4">
<div class="col-md-6">
<h5><i class="fas fa-upload me-2 text-primary"></i>Загрузка изображения</h5>
<div class="api-endpoint">
<strong>POST</strong> /api/images/upload-image
</div>
<p>Загружает изображение во временную папку</p>
<div class="code-block">
const formData = new FormData();
formData.append('image', file);
fetch('/api/images/upload-image', {
method: 'POST',
body: formData
}).then(r => r.json());
</div>
</div>
<div class="col-md-6">
<h5><i class="fas fa-magic me-2 text-success"></i>Обработка изображения</h5>
<div class="api-endpoint">
<strong>POST</strong> /api/images/process-image
</div>
<p>Применяет трансформации и сохраняет финальный файл</p>
<div class="code-block">
fetch('/api/images/process-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tempId: 'temp-123',
rotation: 90,
flipHorizontal: false,
cropData: { x: 0, y: 0, width: 300, height: 200 },
targetFolder: 'routes'
})
});
</div>
</div>
<div class="col-md-6">
<h5><i class="fas fa-list me-2 text-info"></i>Список изображений</h5>
<div class="api-endpoint">
<strong>GET</strong> /api/images/images/{folder}
</div>
<p>Возвращает список всех изображений в папке</p>
<div class="code-block">
// Получить изображения туров
fetch('/api/images/images/routes')
.then(r => r.json())
.then(data => console.log(data.images));
</div>
</div>
<div class="col-md-6">
<h5><i class="fas fa-trash me-2 text-danger"></i>Удаление изображения</h5>
<div class="api-endpoint">
<strong>DELETE</strong> /api/images/image
</div>
<p>Удаляет изображение с сервера</p>
<div class="code-block">
fetch('/api/images/image', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: '/uploads/routes/image.jpg'
})
});
</div>
</div>
</div>
</div>
</section>
<!-- Тестирование -->
<section class="py-5 bg-primary text-white">
<div class="container text-center">
<h2 class="mb-4">🧪 Тестирование системы</h2>
<p class="lead mb-4">Попробуйте все возможности редактора изображений</p>
<div class="d-flex gap-3 justify-content-center">
<a href="/test-editor" class="btn btn-light btn-lg">
<i class="fas fa-play me-2"></i>Открыть тест-редактор
</a>
<a href="/admin" class="btn btn-outline-light btn-lg">
<i class="fas fa-cog me-2"></i>Админ-панель
</a>
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-dark text-white py-3">
<div class="container text-center">
<p class="mb-0">© 2025 Korea Tourism Agency - Система управления изображениями</p>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

BIN
public/images/logo_dark.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,13 @@
<!-- Placeholder для отсутствующих изображений -->
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<rect width="200" height="200" fill="#f8f9fa" stroke="#dee2e6" stroke-width="2"/>
<text x="100" y="90" text-anchor="middle" font-family="Arial" font-size="14" fill="#6c757d">
Нет изображения
</text>
<text x="100" y="110" text-anchor="middle" font-family="Arial" font-size="12" fill="#adb5bd">
No Image
</text>
<circle cx="100" cy="130" r="15" fill="#e9ecef"/>
<rect x="90" y="125" width="20" height="10" fill="#adb5bd"/>
<circle cx="95" cy="128" r="2" fill="#6c757d"/>
</svg>

After

Width:  |  Height:  |  Size: 657 B

View File

@@ -1,5 +1,96 @@
/* Korea Tourism Agency Admin Panel Custom Scripts */
// Функция для открытия редактора изображений
function openImageEditor(fieldName, currentValue) {
const editorUrl = `/image-editor.html?field=${fieldName}&current=${encodeURIComponent(currentValue || '')}`;
const editorWindow = window.open(editorUrl, 'imageEditor', 'width=1200,height=800,scrollbars=yes,resizable=yes');
// Слушаем сообщения от редактора
const messageHandler = (event) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === 'imageSelected' && event.data.targetField === fieldName) {
const field = document.querySelector(`input[name="${fieldName}"], input[id="${fieldName}"]`);
if (field) {
field.value = event.data.path;
field.dispatchEvent(new Event('change', { bubbles: true }));
// Обновляем превью если есть
updateImagePreview(fieldName, event.data.path);
}
window.removeEventListener('message', messageHandler);
editorWindow.close();
}
};
window.addEventListener('message', messageHandler);
// Очистка обработчика при закрытии окна
const checkClosed = setInterval(() => {
if (editorWindow.closed) {
window.removeEventListener('message', messageHandler);
clearInterval(checkClosed);
}
}, 1000);
}
// Функция обновления превью изображения
function updateImagePreview(fieldName, imagePath) {
const previewId = `${fieldName}_preview`;
let preview = document.getElementById(previewId);
if (!preview) {
// Создаем превью если его нет
const field = document.querySelector(`input[name="${fieldName}"], input[id="${fieldName}"]`);
if (field) {
preview = document.createElement('img');
preview.id = previewId;
preview.className = 'img-thumbnail mt-2';
preview.style.maxWidth = '200px';
preview.style.maxHeight = '200px';
field.parentNode.appendChild(preview);
}
}
if (preview) {
preview.src = imagePath || '/images/placeholders/no-image.png';
preview.alt = 'Preview';
}
}
// Функция добавления кнопки редактора к полю
function addImageEditorButton(field) {
const fieldName = field.name || field.id;
if (!fieldName) return;
// Проверяем, не добавлена ли уже кнопка
if (field.parentNode.querySelector('.image-editor-btn')) return;
const wrapper = document.createElement('div');
wrapper.className = 'input-group';
const button = document.createElement('button');
button.type = 'button';
button.className = 'btn btn-outline-secondary image-editor-btn';
button.innerHTML = '<i class="fas fa-images"></i> Выбрать';
button.onclick = () => openImageEditor(fieldName, field.value);
const buttonWrapper = document.createElement('div');
buttonWrapper.className = 'input-group-append';
buttonWrapper.appendChild(button);
// Перестраиваем структуру
field.parentNode.insertBefore(wrapper, field);
wrapper.appendChild(field);
wrapper.appendChild(buttonWrapper);
// Добавляем превью если есть значение
if (field.value) {
updateImagePreview(fieldName, field.value);
}
}
$(document).ready(function() {
// Initialize tooltips
$('[data-toggle="tooltip"]').tooltip();
@@ -12,6 +103,33 @@ $(document).ready(function() {
$('.alert').fadeOut('slow');
}, 5000);
// Добавляем кнопки редактора к полям изображений
$('input[type="text"], input[type="url"]').each(function() {
const field = this;
const fieldName = field.name || field.id || '';
// Проверяем, относится ли поле к изображениям
if (fieldName.includes('image') || fieldName.includes('photo') || fieldName.includes('avatar') ||
fieldName.includes('picture') || fieldName.includes('thumbnail') || fieldName.includes('banner') ||
$(field).closest('label').text().toLowerCase().includes('изображение') ||
$(field).closest('label').text().toLowerCase().includes('картинка') ||
$(field).closest('label').text().toLowerCase().includes('фото')) {
addImageEditorButton(field);
}
});
// Обработчик для динамически добавляемых полей
$(document).on('focus', 'input[type="text"], input[type="url"]', function() {
const field = this;
const fieldName = field.name || field.id || '';
if ((fieldName.includes('image') || fieldName.includes('photo') || fieldName.includes('avatar') ||
fieldName.includes('picture') || fieldName.includes('thumbnail') || fieldName.includes('banner')) &&
!field.parentNode.querySelector('.image-editor-btn')) {
addImageEditorButton(field);
}
});
// Confirm delete actions
$('.btn-delete').on('click', function(e) {
e.preventDefault();

View File

@@ -0,0 +1,13 @@
<!-- Скрипт для AdminJS страниц -->
<script>
// Проверяем, находимся ли мы в админ-панели
if (window.location.pathname.startsWith('/admin')) {
// Загружаем редактор изображений для AdminJS
const script = document.createElement('script');
script.src = '/js/image-editor.js';
script.onload = () => {
console.log('Image Editor loaded for AdminJS');
};
document.head.appendChild(script);
}
</script>

View File

@@ -0,0 +1,309 @@
// JavaScript для интеграции редактора изображений в AdminJS
(function() {
'use strict';
// Функция для открытия редактора изображений
function openImageEditor(inputField, fieldName) {
const currentValue = inputField.value || '';
const editorUrl = `/image-editor-compact.html?field=${fieldName}&current=${encodeURIComponent(currentValue)}`;
// Убираем предыдущие модальные окна
document.querySelectorAll('.image-editor-modal').forEach(modal => modal.remove());
// Создаем модальное окно
const modal = document.createElement('div');
modal.className = 'image-editor-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
border-radius: 8px;
width: 90%;
max-width: 700px;
height: 80%;
max-height: 600px;
position: relative;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✕';
closeBtn.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: #ff4757;
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
z-index: 1;
font-size: 16px;
font-weight: bold;
`;
const iframe = document.createElement('iframe');
iframe.src = editorUrl;
iframe.style.cssText = `
width: 100%;
height: 100%;
border: none;
border-radius: 8px;
`;
// Обработчик сообщений от iframe
const messageHandler = function(event) {
if (event.data.type === 'imageSelected' && event.data.field === fieldName) {
console.log('🖼️ Изображение выбрано:', event.data.url);
inputField.value = event.data.url;
updateImagePreview(inputField, event.data.url);
// Триггерим событие change для обновления формы
const changeEvent = new Event('change', { bubbles: true });
inputField.dispatchEvent(changeEvent);
// Триггерим input событие
const inputEvent = new Event('input', { bubbles: true });
inputField.dispatchEvent(inputEvent);
modal.remove();
window.removeEventListener('message', messageHandler);
} else if (event.data.type === 'editorClosed') {
modal.remove();
window.removeEventListener('message', messageHandler);
}
};
window.addEventListener('message', messageHandler);
closeBtn.onclick = function() {
modal.remove();
window.removeEventListener('message', messageHandler);
};
modal.onclick = function(e) {
if (e.target === modal) {
modal.remove();
window.removeEventListener('message', messageHandler);
}
};
content.appendChild(closeBtn);
content.appendChild(iframe);
modal.appendChild(content);
document.body.appendChild(modal);
}
// Функция обновления превью изображения
function updateImagePreview(inputField, imagePath) {
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
if (!fieldContainer) return;
// Находим или создаем превью
let preview = fieldContainer.querySelector('.image-preview');
if (!preview) {
preview = document.createElement('img');
preview.className = 'image-preview';
preview.style.cssText = `
display: block;
max-width: 180px;
max-height: 120px;
object-fit: cover;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 8px;
margin-bottom: 8px;
`;
// Вставляем превью после кнопки
const button = fieldContainer.querySelector('.image-editor-btn');
if (button) {
const buttonContainer = button.parentNode;
buttonContainer.parentNode.insertBefore(preview, buttonContainer.nextSibling);
} else {
fieldContainer.appendChild(preview);
}
}
if (imagePath && imagePath.trim()) {
preview.src = imagePath + '?t=' + Date.now(); // Добавляем timestamp для обновления
preview.style.display = 'block';
preview.onerror = () => {
preview.style.display = 'none';
};
} else {
preview.style.display = 'none';
}
}
// Функция добавления кнопки редактора к полю
function addImageEditorButton(inputField) {
const fieldName = inputField.name || inputField.id || 'image';
// Проверяем, не добавлена ли уже кнопка
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
if (fieldContainer.querySelector('.image-editor-btn')) {
return;
}
// Создаем контейнер для кнопки
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
margin-top: 8px;
display: flex;
align-items: flex-start;
gap: 10px;
`;
// Создаем кнопку
const button = document.createElement('button');
button.type = 'button';
button.className = 'image-editor-btn';
button.innerHTML = '📷 Выбрать';
button.style.cssText = `
padding: 6px 12px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
`;
button.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
openImageEditor(inputField, fieldName);
};
buttonContainer.appendChild(button);
// Добавляем контейнер после поля ввода
if (inputField.nextSibling) {
inputField.parentNode.insertBefore(buttonContainer, inputField.nextSibling);
} else {
inputField.parentNode.appendChild(buttonContainer);
}
// Добавляем превью если есть значение
if (inputField.value && inputField.value.trim()) {
updateImagePreview(inputField, inputField.value);
}
// Слушаем изменения в поле для обновления превью
inputField.addEventListener('input', () => {
updateImagePreview(inputField, inputField.value);
});
inputField.addEventListener('change', () => {
updateImagePreview(inputField, inputField.value);
});
}
// Функция проверки, является ли поле полем изображения
function isImageField(inputField) {
const fieldName = (inputField.name || inputField.id || '').toLowerCase();
let labelText = '';
// Ищем label для поля
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]');
if (fieldContainer) {
const label = fieldContainer.querySelector('label, .property-label, h3');
if (label) {
labelText = label.textContent.toLowerCase();
}
}
// Проверяем по имени поля и содержанию
const isImageByName = fieldName.includes('image') && !fieldName.includes('title') && !fieldName.includes('alt');
const isImageByLabel = labelText.includes('image') || labelText.includes('изображение') || labelText.includes('фото');
const isImageUrlField = fieldName.includes('image_url') || fieldName === 'image_url';
console.log(`🔍 Проверка поля "${fieldName}": isImageByName=${isImageByName}, isImageByLabel=${isImageByLabel}, isImageUrlField=${isImageUrlField}`);
return isImageUrlField || isImageByName || isImageByLabel;
}
// Функция сканирования и добавления кнопок к полям изображений
function scanAndAddImageButtons() {
console.log('🔍 Сканирование полей для добавления кнопок редактора изображений...');
// Более широкий поиск полей ввода
const inputFields = document.querySelectorAll('input[type="text"], input[type="url"], input:not([type="hidden"]):not([type="submit"]):not([type="button"])');
console.log(`📋 Найдено ${inputFields.length} полей ввода`);
inputFields.forEach((inputField, index) => {
const fieldName = inputField.name || inputField.id || `field_${index}`;
const isImage = isImageField(inputField);
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
const hasButton = fieldContainer.querySelector('.image-editor-btn');
console.log(`🔸 Поле "${fieldName}": isImage=${isImage}, hasButton=${!!hasButton}`);
if (isImage && !hasButton) {
console.log(` Добавляем кнопку для поля "${fieldName}"`);
addImageEditorButton(inputField);
}
});
}
// Инициализация при загрузке DOM
function initialize() {
console.log('🚀 Инициализация селектора изображений AdminJS');
scanAndAddImageButtons();
// Наблюдаем за изменениями в DOM для динамически добавляемых полей
const observer = new MutationObserver((mutations) => {
let shouldScan = false;
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === 1 && (node.tagName === 'INPUT' || node.querySelector('input'))) {
shouldScan = true;
}
});
}
});
if (shouldScan) {
setTimeout(scanAndAddImageButtons, 100);
}
});
observer.observe(document.body, {
childList: true,
subtree: true
});
}
// Ждем загрузки DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
// Также запускаем через задержки для AdminJS
setTimeout(initialize, 1000);
setTimeout(initialize, 3000);
setTimeout(initialize, 5000);
})();

View File

@@ -0,0 +1,300 @@
// JavaScript для интеграции редактора изображений в AdminJS
(function() {
'use strict';
// Функция для открытия редактора изображений
function openImageEditor(inputField, fieldName) {
const currentValue = inputField.value || '';
const editorUrl = `/image-editor-compact.html?field=${fieldName}&current=${encodeURIComponent(currentValue)}`;
// Убираем предыдущие модальные окна
document.querySelectorAll('.image-editor-modal').forEach(modal => modal.remove());
// Создаем модальное окно
const modal = document.createElement('div');
modal.className = 'image-editor-modal';
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
border-radius: 8px;
width: 90%;
max-width: 700px;
height: 80%;
max-height: 600px;
position: relative;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
`;
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✕';
closeBtn.style.cssText = `
position: absolute;
top: 15px;
right: 15px;
background: #ff4757;
color: white;
border: none;
border-radius: 50%;
width: 35px;
height: 35px;
cursor: pointer;
z-index: 1;
font-size: 18px;
font-weight: bold;
`;
const iframe = document.createElement('iframe');
iframe.src = editorUrl;
iframe.style.cssText = `
width: 100%;
height: 100%;
border: none;
border-radius: 8px;
`;
// Обработчик закрытия
const closeModal = () => {
document.body.removeChild(modal);
window.removeEventListener('message', handleMessage);
};
closeBtn.onclick = closeModal;
// Обработчик сообщений от редактора
const handleMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === 'imageSelected' && event.data.targetField === fieldName) {
inputField.value = event.data.path;
// Триггерим события изменения
inputField.dispatchEvent(new Event('input', { bubbles: true }));
inputField.dispatchEvent(new Event('change', { bubbles: true }));
// Обновляем превью если есть
updateImagePreview(inputField, event.data.path);
closeModal();
}
};
window.addEventListener('message', handleMessage);
content.appendChild(closeBtn);
content.appendChild(iframe);
modal.appendChild(content);
document.body.appendChild(modal);
// Закрытие по клику на фон
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeModal();
}
});
}
// Функция обновления превью изображения
function updateImagePreview(inputField, imagePath) {
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]');
if (!fieldContainer) return;
// Находим или создаем превью
let preview = fieldContainer.querySelector('.image-preview');
if (!preview) {
preview = document.createElement('img');
preview.className = 'image-preview';
preview.style.cssText = `
display: block;
max-width: 180px;
max-height: 120px;
object-fit: cover;
border: 1px solid #ddd;
border-radius: 4px;
margin-top: 8px;
margin-bottom: 8px;
`;
// Вставляем превью после кнопки
const button = fieldContainer.querySelector('.image-editor-btn');
if (button && button.nextSibling) {
button.parentNode.insertBefore(preview, button.nextSibling);
} else {
inputField.parentNode.appendChild(preview);
}
}
if (imagePath && imagePath.trim()) {
preview.src = imagePath + '?t=' + Date.now(); // Добавляем timestamp для обновления
preview.style.display = 'block';
preview.onerror = () => {
preview.style.display = 'none';
};
} else {
preview.style.display = 'none';
}
}
// Функция добавления кнопки редактора к полю
function addImageEditorButton(inputField) {
const fieldName = inputField.name || inputField.id || 'image';
// Проверяем, не добавлена ли уже кнопка
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
if (fieldContainer.querySelector('.image-editor-btn')) {
return;
}
// Создаем контейнер для кнопки и превью
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
margin-top: 8px;
display: flex;
align-items: flex-start;
gap: 10px;
`;
// Создаем кнопку
const button = document.createElement('button');
button.type = 'button';
button.className = 'image-editor-btn';
button.innerHTML = '📷 Выбрать';
button.style.cssText = `
padding: 6px 12px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
white-space: nowrap;
`;
button.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
openImageEditor(inputField, fieldName);
};
buttonContainer.appendChild(button);
// Добавляем контейнер после поля ввода
if (inputField.nextSibling) {
inputField.parentNode.insertBefore(buttonContainer, inputField.nextSibling);
} else {
inputField.parentNode.appendChild(buttonContainer);
}
// Добавляем превью если есть значение
if (inputField.value && inputField.value.trim()) {
updateImagePreview(inputField, inputField.value);
}
// Слушаем изменения в поле для обновления превью
inputField.addEventListener('input', () => {
updateImagePreview(inputField, inputField.value);
});
inputField.addEventListener('change', () => {
updateImagePreview(inputField, inputField.value);
});
}
// Функция проверки, является ли поле полем изображения
function isImageField(inputField) {
const fieldName = (inputField.name || inputField.id || '').toLowerCase();
let labelText = '';
// Ищем label для поля
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]');
if (fieldContainer) {
const label = fieldContainer.querySelector('label, .property-label, h3');
if (label) {
labelText = label.textContent.toLowerCase();
}
}
// Проверяем по имени поля или тексту label
return fieldName.includes('image') ||
fieldName.includes('photo') ||
fieldName.includes('avatar') ||
fieldName.includes('picture') ||
fieldName.includes('banner') ||
fieldName.includes('thumbnail') ||
fieldName.includes('url') && (labelText.includes('image') || labelText.includes('изображение')) ||
labelText.includes('изображение') ||
labelText.includes('картинка') ||
labelText.includes('фото') ||
labelText.includes('image') ||
labelText.includes('picture');
}
// Функция сканирования и добавления кнопок к полям изображений
function scanAndAddImageButtons() {
console.log('🔍 Сканирование полей для добавления кнопок редактора изображений...');
// Более широкий поиск полей ввода
const inputFields = document.querySelectorAll('input[type="text"], input[type="url"], input:not([type="hidden"]):not([type="submit"]):not([type="button"])');
console.log(`📋 Найдено ${inputFields.length} полей ввода`);
inputFields.forEach((inputField, index) => {
const fieldName = inputField.name || inputField.id || `field_${index}`;
const isImage = isImageField(inputField);
const hasButton = inputField.parentNode.querySelector('.image-editor-btn');
console.log(`🔸 Поле "${fieldName}": isImage=${isImage}, hasButton=${hasButton}`);
if (isImage && !hasButton) {
console.log(` Добавляем кнопку для поля "${fieldName}"`);
addImageEditorButton(inputField);
}
});
}
// Инициализация при загрузке DOM
function initialize() {
console.log('🚀 Инициализация селектора изображений AdminJS');
scanAndAddImageButtons();
// Наблюдаем за изменениями в DOM для динамически добавляемых полей
const observer = new MutationObserver(() => {
scanAndAddImageButtons();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Периодическое сканирование для надежности
setInterval(scanAndAddImageButtons, 2000);
}
// Ждем загрузки DOM
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initialize);
} else {
initialize();
}
// Также запускаем через задержки для AdminJS
setTimeout(initialize, 1000);
setTimeout(initialize, 3000);
setTimeout(initialize, 5000);
})();

690
public/js/image-editor.js Normal file
View File

@@ -0,0 +1,690 @@
/**
* Image Editor Modal Component
* Предоставляет интерфейс для загрузки, обрезки и редактирования изображений
*/
class ImageEditor {
constructor(options = {}) {
this.options = {
targetFolder: 'routes',
aspectRatio: null, // null = свободная обрезка, или например 16/9
maxWidth: 1200,
maxHeight: 800,
...options
};
this.modal = null;
this.canvas = null;
this.ctx = null;
this.image = null;
this.imageData = null;
this.cropBox = null;
this.isDragging = false;
this.lastMousePos = { x: 0, y: 0 };
this.rotation = 0;
this.flipHorizontal = false;
this.flipVertical = false;
this.onSave = options.onSave || (() => {});
this.onCancel = options.onCancel || (() => {});
this.createModal();
}
createModal() {
// Создаем модальное окно
const modalHTML = `
<div class="image-editor-overlay">
<div class="image-editor-modal">
<div class="image-editor-header">
<h3>Редактор изображений</h3>
<button class="close-btn">&times;</button>
</div>
<div class="image-editor-body">
<!-- Область загрузки -->
<div class="upload-area" id="uploadArea">
<div class="upload-content">
<div class="upload-icon">📷</div>
<p>Перетащите изображение сюда или <button class="btn-link" id="selectFileBtn">выберите файл</button></p>
<input type="file" id="fileInput" accept="image/*" style="display: none;">
</div>
</div>
<!-- Область редактирования -->
<div class="editor-area" id="editorArea" style="display: none;">
<div class="editor-toolbar">
<button class="tool-btn" id="rotateLeftBtn" title="Повернуть влево">↺</button>
<button class="tool-btn" id="rotateRightBtn" title="Повернуть вправо">↻</button>
<button class="tool-btn" id="flipHorizontalBtn" title="Отразить горизонтально">⟷</button>
<button class="tool-btn" id="flipVerticalBtn" title="Отразить вертикально">↕</button>
<button class="tool-btn" id="resetCropBtn" title="Сбросить обрезку">⌕</button>
</div>
<div class="canvas-container">
<canvas id="editorCanvas"></canvas>
<div class="crop-overlay" id="cropOverlay">
<div class="crop-box" id="cropBox">
<div class="crop-handle nw"></div>
<div class="crop-handle ne"></div>
<div class="crop-handle sw"></div>
<div class="crop-handle se"></div>
<div class="crop-handle n"></div>
<div class="crop-handle s"></div>
<div class="crop-handle e"></div>
<div class="crop-handle w"></div>
</div>
</div>
</div>
<div class="image-info">
<span id="imageInfo">Размер: 0x0</span>
<span id="cropInfo">Обрезка: не выбрана</span>
</div>
</div>
</div>
<div class="image-editor-footer">
<button class="btn btn-secondary" id="cancelBtn">Отмена</button>
<button class="btn btn-primary" id="saveBtn" disabled>Сохранить</button>
</div>
</div>
</div>
`;
// Добавляем стили
if (!document.getElementById('image-editor-styles')) {
const styles = document.createElement('style');
styles.id = 'image-editor-styles';
styles.textContent = `
.image-editor-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
display: flex;
justify-content: center;
align-items: center;
z-index: 10000;
}
.image-editor-modal {
background: white;
border-radius: 8px;
width: 90vw;
max-width: 900px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.image-editor-header {
padding: 20px;
border-bottom: 1px solid #ddd;
display: flex;
justify-content: space-between;
align-items: center;
}
.image-editor-header h3 {
margin: 0;
color: #333;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #999;
}
.close-btn:hover {
color: #333;
}
.image-editor-body {
flex: 1;
padding: 20px;
overflow: auto;
}
.upload-area {
border: 2px dashed #ccc;
border-radius: 8px;
padding: 40px;
text-align: center;
background: #f9f9f9;
transition: all 0.3s ease;
}
.upload-area.dragover {
border-color: #007bff;
background: #e3f2fd;
}
.upload-content .upload-icon {
font-size: 48px;
margin-bottom: 16px;
}
.upload-content p {
margin: 0;
color: #666;
}
.btn-link {
background: none;
border: none;
color: #007bff;
text-decoration: underline;
cursor: pointer;
}
.editor-area {
display: flex;
flex-direction: column;
gap: 16px;
}
.editor-toolbar {
display: flex;
gap: 8px;
justify-content: center;
padding: 12px;
background: #f8f9fa;
border-radius: 4px;
}
.tool-btn {
background: white;
border: 1px solid #ddd;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.tool-btn:hover {
background: #007bff;
color: white;
border-color: #007bff;
}
.canvas-container {
position: relative;
display: flex;
justify-content: center;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
}
#editorCanvas {
max-width: 100%;
max-height: 400px;
border: 1px solid #ddd;
}
.crop-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
.crop-box {
position: absolute;
border: 2px solid #007bff;
background: rgba(0, 123, 255, 0.1);
pointer-events: all;
cursor: move;
}
.crop-handle {
position: absolute;
width: 10px;
height: 10px;
background: #007bff;
border: 1px solid white;
border-radius: 2px;
}
.crop-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
.crop-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
.crop-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
.crop-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
.crop-handle.n { top: -5px; left: 50%; margin-left: -5px; cursor: n-resize; }
.crop-handle.s { bottom: -5px; left: 50%; margin-left: -5px; cursor: s-resize; }
.crop-handle.e { top: 50%; right: -5px; margin-top: -5px; cursor: e-resize; }
.crop-handle.w { top: 50%; left: -5px; margin-top: -5px; cursor: w-resize; }
.image-info {
display: flex;
justify-content: space-between;
padding: 8px;
background: #f8f9fa;
border-radius: 4px;
font-size: 12px;
color: #666;
}
.image-editor-footer {
padding: 20px;
border-top: 1px solid #ddd;
display: flex;
gap: 12px;
justify-content: flex-end;
}
.btn {
padding: 8px 16px;
border-radius: 4px;
border: 1px solid;
cursor: pointer;
font-size: 14px;
}
.btn-secondary {
background: #f8f9fa;
color: #333;
border-color: #ddd;
}
.btn-primary {
background: #007bff;
color: white;
border-color: #007bff;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
document.head.appendChild(styles);
}
// Добавляем модальное окно в DOM
document.body.insertAdjacentHTML('beforeend', modalHTML);
this.modal = document.querySelector('.image-editor-overlay');
this.bindEvents();
}
bindEvents() {
// Закрытие модального окна
this.modal.querySelector('.close-btn').addEventListener('click', () => this.close());
this.modal.querySelector('#cancelBtn').addEventListener('click', () => this.close());
// Клик по overlay для закрытия
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) this.close();
});
// Загрузка файла
const fileInput = this.modal.querySelector('#fileInput');
const selectFileBtn = this.modal.querySelector('#selectFileBtn');
const uploadArea = this.modal.querySelector('#uploadArea');
selectFileBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
// Drag & Drop
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
this.loadImage(files[0]);
}
});
// Инструменты редактирования
this.modal.querySelector('#rotateLeftBtn').addEventListener('click', () => this.rotate(-90));
this.modal.querySelector('#rotateRightBtn').addEventListener('click', () => this.rotate(90));
this.modal.querySelector('#flipHorizontalBtn').addEventListener('click', () => this.flipHorizontal = !this.flipHorizontal, this.redraw());
this.modal.querySelector('#flipVerticalBtn').addEventListener('click', () => this.flipVertical = !this.flipVertical, this.redraw());
this.modal.querySelector('#resetCropBtn').addEventListener('click', () => this.resetCrop());
// Сохранение
this.modal.querySelector('#saveBtn').addEventListener('click', () => this.save());
}
handleFileSelect(e) {
const file = e.target.files[0];
if (file) {
this.loadImage(file);
}
}
async loadImage(file) {
if (!file.type.startsWith('image/')) {
alert('Пожалуйста, выберите изображение');
return;
}
const formData = new FormData();
formData.append('image', file);
try {
const response = await fetch('/api/images/upload-image', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
this.imageData = result;
this.showEditor();
this.loadImageToCanvas(result.tempUrl);
} else {
alert(result.error || 'Ошибка загрузки изображения');
}
} catch (error) {
console.error('Upload error:', error);
alert('Ошибка загрузки изображения');
}
}
loadImageToCanvas(imageUrl) {
this.image = new Image();
this.image.onload = () => {
this.initCanvas();
this.initCropBox();
this.redraw();
this.updateInfo();
this.modal.querySelector('#saveBtn').disabled = false;
};
this.image.src = imageUrl;
}
showEditor() {
this.modal.querySelector('#uploadArea').style.display = 'none';
this.modal.querySelector('#editorArea').style.display = 'block';
}
initCanvas() {
this.canvas = this.modal.querySelector('#editorCanvas');
this.ctx = this.canvas.getContext('2d');
// Вычисляем размеры canvas
const containerWidth = 600; // максимальная ширина
const containerHeight = 400; // максимальная высота
const imageRatio = this.image.width / this.image.height;
const containerRatio = containerWidth / containerHeight;
if (imageRatio > containerRatio) {
this.canvas.width = containerWidth;
this.canvas.height = containerWidth / imageRatio;
} else {
this.canvas.width = containerHeight * imageRatio;
this.canvas.height = containerHeight;
}
this.scaleX = this.canvas.width / this.image.width;
this.scaleY = this.canvas.height / this.image.height;
}
initCropBox() {
const overlay = this.modal.querySelector('#cropOverlay');
const cropBox = this.modal.querySelector('#cropBox');
// Устанавливаем размеры overlay как у canvas
const canvasRect = this.canvas.getBoundingClientRect();
const containerRect = this.canvas.parentElement.getBoundingClientRect();
overlay.style.width = `${canvasRect.width}px`;
overlay.style.height = `${canvasRect.height}px`;
overlay.style.left = `${canvasRect.left - containerRect.left}px`;
overlay.style.top = `${canvasRect.top - containerRect.top}px`;
// Инициализируем crop box (50% от центра)
const boxWidth = canvasRect.width * 0.6;
const boxHeight = canvasRect.height * 0.6;
const boxLeft = (canvasRect.width - boxWidth) / 2;
const boxTop = (canvasRect.height - boxHeight) / 2;
cropBox.style.width = `${boxWidth}px`;
cropBox.style.height = `${boxHeight}px`;
cropBox.style.left = `${boxLeft}px`;
cropBox.style.top = `${boxTop}px`;
this.cropBox = {
x: boxLeft / canvasRect.width,
y: boxTop / canvasRect.height,
width: boxWidth / canvasRect.width,
height: boxHeight / canvasRect.height
};
this.bindCropEvents(cropBox, overlay);
}
bindCropEvents(cropBox, overlay) {
let resizing = false;
let moving = false;
let startPos = { x: 0, y: 0 };
let startBox = {};
// Обработчик для перемещения
cropBox.addEventListener('mousedown', (e) => {
if (e.target === cropBox) {
moving = true;
startPos = { x: e.clientX, y: e.clientY };
startBox = { ...this.cropBox };
e.preventDefault();
}
});
// Обработчик для изменения размера
const handles = cropBox.querySelectorAll('.crop-handle');
handles.forEach(handle => {
handle.addEventListener('mousedown', (e) => {
resizing = handle.className.replace('crop-handle ', '');
startPos = { x: e.clientX, y: e.clientY };
startBox = { ...this.cropBox };
e.preventDefault();
e.stopPropagation();
});
});
// Обработчики движения и отпускания мыши
document.addEventListener('mousemove', (e) => {
if (moving || resizing) {
this.updateCropBox(e, startPos, startBox, moving ? 'move' : resizing, overlay);
}
});
document.addEventListener('mouseup', () => {
moving = false;
resizing = false;
});
}
updateCropBox(e, startPos, startBox, action, overlay) {
const overlayRect = overlay.getBoundingClientRect();
const deltaX = (e.clientX - startPos.x) / overlayRect.width;
const deltaY = (e.clientY - startPos.y) / overlayRect.height;
let newBox = { ...startBox };
if (action === 'move') {
newBox.x = Math.max(0, Math.min(1 - startBox.width, startBox.x + deltaX));
newBox.y = Math.max(0, Math.min(1 - startBox.height, startBox.y + deltaY));
} else {
// Изменение размера в зависимости от handle
if (action.includes('n')) newBox.y += deltaY, newBox.height -= deltaY;
if (action.includes('s')) newBox.height += deltaY;
if (action.includes('w')) newBox.x += deltaX, newBox.width -= deltaX;
if (action.includes('e')) newBox.width += deltaX;
// Ограничиваем минимальные размеры
if (newBox.width < 0.1) newBox.width = 0.1;
if (newBox.height < 0.1) newBox.height = 0.1;
// Ограничиваем границы
if (newBox.x < 0) newBox.x = 0;
if (newBox.y < 0) newBox.y = 0;
if (newBox.x + newBox.width > 1) newBox.width = 1 - newBox.x;
if (newBox.y + newBox.height > 1) newBox.height = 1 - newBox.y;
}
this.cropBox = newBox;
this.updateCropBoxDisplay(overlay);
this.updateInfo();
}
updateCropBoxDisplay(overlay) {
const cropBoxElement = this.modal.querySelector('#cropBox');
const overlayRect = overlay.getBoundingClientRect();
cropBoxElement.style.left = `${this.cropBox.x * overlayRect.width}px`;
cropBoxElement.style.top = `${this.cropBox.y * overlayRect.height}px`;
cropBoxElement.style.width = `${this.cropBox.width * overlayRect.width}px`;
cropBoxElement.style.height = `${this.cropBox.height * overlayRect.height}px`;
}
rotate(degrees) {
this.rotation = (this.rotation + degrees) % 360;
this.redraw();
this.updateInfo();
}
redraw() {
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
this.ctx.save();
// Перемещаем к центру
this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
// Поворот
this.ctx.rotate(this.rotation * Math.PI / 180);
// Отражение
this.ctx.scale(this.flipHorizontal ? -1 : 1, this.flipVertical ? -1 : 1);
// Рисуем изображение
this.ctx.drawImage(
this.image,
-this.canvas.width / 2,
-this.canvas.height / 2,
this.canvas.width,
this.canvas.height
);
this.ctx.restore();
}
resetCrop() {
// Сброс crop box к исходному состоянию
this.cropBox = { x: 0.2, y: 0.2, width: 0.6, height: 0.6 };
this.updateCropBoxDisplay(this.modal.querySelector('#cropOverlay'));
this.updateInfo();
}
updateInfo() {
const imageInfo = this.modal.querySelector('#imageInfo');
const cropInfo = this.modal.querySelector('#cropInfo');
imageInfo.textContent = `Размер: ${this.image.width}x${this.image.height}`;
const cropWidth = Math.round(this.cropBox.width * this.image.width);
const cropHeight = Math.round(this.cropBox.height * this.image.height);
cropInfo.textContent = `Обрезка: ${cropWidth}x${cropHeight}`;
}
async save() {
const saveBtn = this.modal.querySelector('#saveBtn');
saveBtn.disabled = true;
saveBtn.textContent = 'Сохранение...';
try {
const cropData = {
x: this.cropBox.x * this.image.width,
y: this.cropBox.y * this.image.height,
width: this.cropBox.width * this.image.width,
height: this.cropBox.height * this.image.height
};
const response = await fetch('/api/images/process-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
tempId: this.imageData.tempId,
rotation: this.rotation,
flipHorizontal: this.flipHorizontal,
flipVertical: this.flipVertical,
cropData,
targetFolder: this.options.targetFolder
})
});
const result = await response.json();
if (result.success) {
this.onSave(result.url);
this.close();
} else {
alert(result.error || 'Ошибка сохранения');
saveBtn.disabled = false;
saveBtn.textContent = 'Сохранить';
}
} catch (error) {
console.error('Save error:', error);
alert('Ошибка сохранения изображения');
saveBtn.disabled = false;
saveBtn.textContent = 'Сохранить';
}
}
close() {
// Удаляем временный файл
if (this.imageData && this.imageData.tempId) {
fetch(`/api/images/temp-image/${this.imageData.tempId}`, {
method: 'DELETE'
}).catch(console.error);
}
this.onCancel();
if (this.modal) {
this.modal.remove();
}
}
show() {
if (this.modal) {
this.modal.style.display = 'flex';
}
}
}
// Глобально доступная функция для открытия редактора
window.openImageEditor = function(options = {}) {
return new Promise((resolve, reject) => {
const editor = new ImageEditor({
...options,
onSave: (url) => resolve(url),
onCancel: () => reject(new Error('Canceled'))
});
editor.show();
});
};

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

103
public/test-editor.html Normal file
View File

@@ -0,0 +1,103 @@
<!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;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.test-container {
border: 1px solid #ddd;
padding: 20px;
margin: 20px 0;
border-radius: 8px;
}
.result-image {
max-width: 300px;
max-height: 200px;
border: 1px solid #ddd;
margin: 10px 0;
}
.btn {
background: #007bff;
color: white;
padding: 10px 20px;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 5px;
}
.btn:hover {
background: #0056b3;
}
</style>
</head>
<body>
<h1>Тестирование редактора изображений</h1>
<div class="test-container">
<h3>Тест для туров (routes)</h3>
<button class="btn" onclick="testImageEditor('routes')">Открыть редактор для туров</button>
<div id="routes-result"></div>
</div>
<div class="test-container">
<h3>Тест для гидов (guides)</h3>
<button class="btn" onclick="testImageEditor('guides')">Открыть редактор для гидов</button>
<div id="guides-result"></div>
</div>
<div class="test-container">
<h3>Тест для статей (articles)</h3>
<button class="btn" onclick="testImageEditor('articles')">Открыть редактор для статей</button>
<div id="articles-result"></div>
</div>
<!-- Подключаем редактор изображений -->
<script src="/js/image-editor.js"></script>
<script>
function testImageEditor(targetFolder) {
if (typeof window.openImageEditor === 'function') {
window.openImageEditor({
targetFolder: targetFolder,
onSave: (url) => {
console.log('Saved image:', url);
const resultDiv = document.getElementById(targetFolder + '-result');
resultDiv.innerHTML = `
<p>Изображение сохранено: <strong>${url}</strong></p>
<img src="${url}" alt="Result" class="result-image">
`;
}
}).catch((error) => {
if (error.message !== 'Canceled') {
console.error('Editor error:', error);
alert('Ошибка редактора: ' + error.message);
}
});
} else {
alert('Редактор изображений не загружен!');
}
}
// Проверяем доступность API
async function checkAPI() {
try {
const response = await fetch('/api/images/images/routes');
const result = await response.json();
console.log('API check:', result);
} catch (error) {
console.error('API not available:', error);
}
}
// Проверяем при загрузке страницы
checkAPI();
</script>
</body>
</html>

View File

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

View File

@@ -0,0 +1,191 @@
<!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">
</head>
<body>
<div class="container mt-5">
<h1>Тест интеграции редактора изображений</h1>
<p>Этот файл демонстрирует, как редактор изображений будет работать с AdminJS.</p>
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>Поле изображения маршрута</h5>
</div>
<div class="card-body">
<label for="route_image_url" class="form-label">Изображение маршрута</label>
<div class="input-group">
<input type="text" id="route_image_url" name="route_image_url" class="form-control"
placeholder="/uploads/routes/example.jpg" value="/uploads/routes/seoul-city-tour.jpg">
<button type="button" class="btn btn-outline-secondary" onclick="openImageEditor('route_image_url', document.getElementById('route_image_url').value)">
<i class="fas fa-images"></i> Выбрать
</button>
</div>
<img id="route_image_url_preview" src="/uploads/routes/seoul-city-tour.jpg"
class="img-thumbnail mt-2" style="max-width: 200px; max-height: 200px;">
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5>Поле изображения гида</h5>
</div>
<div class="card-body">
<label for="guide_image_url" class="form-label">Фотография гида</label>
<div class="input-group">
<input type="text" id="guide_image_url" name="guide_image_url" class="form-control"
placeholder="/uploads/guides/example.jpg" value="/uploads/guides/guide-profile.jpg">
<button type="button" class="btn btn-outline-secondary" onclick="openImageEditor('guide_image_url', document.getElementById('guide_image_url').value)">
<i class="fas fa-images"></i> Выбрать
</button>
</div>
<img id="guide_image_url_preview" src="/uploads/guides/guide-profile.jpg"
class="img-thumbnail mt-2" style="max-width: 200px; max-height: 200px;">
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-header">
<h5>Доступные изображения</h5>
</div>
<div class="card-body">
<div id="imageList" class="row">
<!-- Будет заполнено динамически -->
</div>
</div>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-md-12">
<div class="alert alert-info">
<h6>Как использовать:</h6>
<ol>
<li>Нажмите кнопку "Выбрать" рядом с полем изображения</li>
<li>Откроется редактор изображений в новом окне</li>
<li>Выберите изображение из галереи, загрузите новое или укажите URL</li>
<li>Нажмите "Выбрать" в редакторе</li>
<li>Поле автоматически обновится с выбранным путем</li>
</ol>
</div>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js"></script>
<script>
// Функция для открытия редактора изображений
function openImageEditor(fieldName, currentValue) {
const editorUrl = `/image-editor.html?field=${fieldName}&current=${encodeURIComponent(currentValue || '')}`;
const editorWindow = window.open(editorUrl, 'imageEditor', 'width=1200,height=800,scrollbars=yes,resizable=yes');
// Слушаем сообщения от редактора
const messageHandler = (event) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === 'imageSelected' && event.data.targetField === fieldName) {
const field = document.getElementById(fieldName);
const preview = document.getElementById(fieldName + '_preview');
if (field) {
field.value = event.data.path;
field.dispatchEvent(new Event('change', { bubbles: true }));
}
if (preview) {
preview.src = event.data.path;
}
window.removeEventListener('message', messageHandler);
editorWindow.close();
showSuccess(`Изображение обновлено: ${event.data.path}`);
}
};
window.addEventListener('message', messageHandler);
// Очистка обработчика при закрытии окна
const checkClosed = setInterval(() => {
if (editorWindow.closed) {
window.removeEventListener('message', messageHandler);
clearInterval(checkClosed);
}
}, 1000);
}
// Функция показа уведомления об успехе
function showSuccess(message) {
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3';
alert.style.zIndex = '9999';
alert.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 3000);
}
// Загрузка списка изображений
async function loadImageList() {
try {
const response = await fetch('/api/images/gallery?folder=all');
const result = await response.json();
if (result.success) {
renderImageList(result.data);
} else {
document.getElementById('imageList').innerHTML = '<p class="text-muted">Ошибка загрузки изображений</p>';
}
} catch (error) {
document.getElementById('imageList').innerHTML = '<p class="text-muted">Ошибка: ' + error.message + '</p>';
}
}
// Отображение списка изображений
function renderImageList(images) {
const container = document.getElementById('imageList');
if (images.length === 0) {
container.innerHTML = '<p class="text-muted">Изображения не найдены</p>';
return;
}
container.innerHTML = images.slice(0, 12).map(img => `
<div class="col-md-2 col-sm-3 col-4 mb-3">
<div class="card h-100">
<img src="${img.path}" class="card-img-top" style="height: 100px; object-fit: cover;" alt="${img.name}">
<div class="card-body p-2">
<small class="card-title text-truncate d-block">${img.name}</small>
<small class="text-muted">${img.folder}</small>
</div>
</div>
</div>
`).join('');
}
// Загружаем изображения при загрузке страницы
document.addEventListener('DOMContentLoaded', loadImageList);
</script>
</body>
</html>

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

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

View File

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

View File

@@ -7,6 +7,7 @@ import compression from 'compression';
import morgan from 'morgan';
import methodOverride from 'method-override';
import formatters from './helpers/formatters.js';
import SiteSettingsHelper from './helpers/site-settings.js';
import { adminJs, router as adminRouter } from './config/adminjs-simple.js';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
@@ -42,7 +43,7 @@ app.use(helmet({
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdnjs.cloudflare.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://code.jquery.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://code.jquery.com"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
connectSrc: ["'self'"],
},
@@ -76,8 +77,8 @@ app.use(session({
app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../views'));
// Global template variables
app.use((req, res, next) => {
// Global template variables with site settings
app.use(async (req, res, next) => {
res.locals.siteName = process.env.SITE_NAME || 'Корея Тур Агентство';
res.locals.siteDescription = process.env.SITE_DESCRIPTION || 'Откройте для себя красоту Кореи';
res.locals.user = req.session.user || null;
@@ -85,6 +86,14 @@ app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.page = 'home'; // default page
// Load site settings for templates
try {
res.locals.siteSettings = await SiteSettingsHelper.getAllSettings();
} catch (error) {
console.error('Error loading site settings for templates:', error);
res.locals.siteSettings = {};
}
// Add all helper functions to template globals
Object.assign(res.locals, formatters);
@@ -148,7 +157,14 @@ const toursRouter = (await import('./routes/tours.js')).default;
const guidesRouter = (await import('./routes/guides.js')).default;
const articlesRouter = (await import('./routes/articles.js')).default;
const apiRouter = (await import('./routes/api.js')).default;
const settingsRouter = (await import('./routes/settings.js')).default;
const ratingsRouter = (await import('./routes/ratings.js')).default;
const imagesRouter = (await import('./routes/images.js')).default;
const crudRouter = (await import('./routes/crud.js')).default;
const testRouter = (await import('./routes/test.js')).default;
const adminToolsRouter = (await import('./routes/admin-tools.js')).default;
const adminCalendarRouter = (await import('./routes/admin-calendar.js')).default;
const guideSchedulesRouter = (await import('./routes/guide-schedules.js')).default;
app.use('/', indexRouter);
app.use('/routes', toursRouter);
@@ -156,6 +172,13 @@ app.use('/guides', guidesRouter);
app.use('/articles', articlesRouter);
app.use('/api', apiRouter);
app.use('/api', ratingsRouter);
app.use('/', settingsRouter); // Settings routes (CSS and API)
app.use('/api/images', imagesRouter); // Image management routes
app.use('/api/crud', crudRouter); // CRUD API routes
app.use('/api', testRouter); // Test routes
app.use('/', adminToolsRouter); // Admin tools routes
app.use('/admin', adminCalendarRouter); // Admin calendar routes
app.use('/api/guide-schedules', guideSchedulesRouter); // Guide schedules API
// Health check endpoint
app.get('/health', (req, res) => {
@@ -166,6 +189,16 @@ app.get('/health', (req, res) => {
});
});
// Test image editor endpoint
app.get('/test-editor', (req, res) => {
res.sendFile(path.join(__dirname, '../public/test-editor.html'));
});
// Image system documentation
app.get('/image-docs', (req, res) => {
res.sendFile(path.join(__dirname, '../public/image-system-docs.html'));
});
// Error handling
app.use((req, res) => {
res.status(404).render('error', {

View File

@@ -0,0 +1,271 @@
import React, { useState, useEffect } from 'react';
const ImageEditor = ({ record, property, onChange }) => {
const [currentValue, setCurrentValue] = useState(record.params[property.name] || '');
const [showEditor, setShowEditor] = useState(false);
const [images, setImages] = useState([]);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState('gallery');
const [uploadFile, setUploadFile] = useState(null);
// Загрузка галереи изображений
useEffect(() => {
if (showEditor) {
loadGallery();
}
}, [showEditor]);
const loadGallery = async () => {
setLoading(true);
try {
const response = await fetch('/api/images/gallery?folder=all');
const result = await response.json();
if (result.success) {
setImages(result.data);
}
} catch (error) {
console.error('Error loading gallery:', error);
} finally {
setLoading(false);
}
};
const handleImageSelect = (imagePath) => {
setCurrentValue(imagePath);
onChange(property.name, imagePath);
setShowEditor(false);
};
const handleFileUpload = async () => {
if (!uploadFile) return;
const formData = new FormData();
formData.append('image', uploadFile);
setLoading(true);
try {
const response = await fetch('/api/images/upload', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
handleImageSelect(result.data.path);
await loadGallery(); // Обновляем галерею
} else {
alert('Ошибка загрузки: ' + result.error);
}
} catch (error) {
alert('Ошибка загрузки: ' + error.message);
} finally {
setLoading(false);
}
};
const getFolderFromPropertyName = () => {
const name = property.name.toLowerCase();
if (name.includes('route')) return 'routes';
if (name.includes('guide')) return 'guides';
if (name.includes('article')) return 'articles';
return 'general';
};
return (
<div>
<label style={{ fontWeight: 'bold', marginBottom: '8px', display: 'block' }}>
{property.label || property.name}
</label>
{/* Поле ввода и кнопка */}
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
<input
type="text"
value={currentValue}
onChange={(e) => {
setCurrentValue(e.target.value);
onChange(property.name, e.target.value);
}}
placeholder="Путь к изображению"
style={{
flex: 1,
padding: '8px 12px',
border: '1px solid #ddd',
borderRadius: '4px',
marginRight: '8px'
}}
/>
<button
type="button"
onClick={() => setShowEditor(!showEditor)}
style={{
padding: '8px 16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
{showEditor ? 'Закрыть' : 'Выбрать'}
</button>
</div>
{/* Предварительный просмотр */}
{currentValue && (
<div style={{ marginBottom: '12px' }}>
<img
src={currentValue}
alt="Preview"
style={{
maxWidth: '200px',
maxHeight: '200px',
objectFit: 'cover',
border: '1px solid #ddd',
borderRadius: '4px'
}}
onError={(e) => {
e.target.style.display = 'none';
}}
/>
</div>
)}
{/* Редактор изображений */}
{showEditor && (
<div style={{
border: '1px solid #ddd',
borderRadius: '4px',
padding: '16px',
backgroundColor: '#f9f9f9'
}}>
{/* Вкладки */}
<div style={{ marginBottom: '16px' }}>
<button
type="button"
onClick={() => setActiveTab('gallery')}
style={{
padding: '8px 16px',
marginRight: '8px',
backgroundColor: activeTab === 'gallery' ? '#007bff' : '#f8f9fa',
color: activeTab === 'gallery' ? 'white' : '#495057',
border: '1px solid #dee2e6',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Галерея
</button>
<button
type="button"
onClick={() => setActiveTab('upload')}
style={{
padding: '8px 16px',
backgroundColor: activeTab === 'upload' ? '#007bff' : '#f8f9fa',
color: activeTab === 'upload' ? 'white' : '#495057',
border: '1px solid #dee2e6',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Загрузить
</button>
</div>
{/* Контент вкладки Галерея */}
{activeTab === 'gallery' && (
<div>
{loading ? (
<div style={{ textAlign: 'center', padding: '20px' }}>
Загрузка...
</div>
) : (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
gap: '12px',
maxHeight: '400px',
overflowY: 'auto'
}}>
{images.map((image) => (
<div
key={image.path}
onClick={() => handleImageSelect(image.path)}
style={{
cursor: 'pointer',
border: currentValue === image.path ? '2px solid #007bff' : '1px solid #ddd',
borderRadius: '4px',
overflow: 'hidden',
backgroundColor: 'white'
}}
>
<img
src={image.path}
alt={image.name}
style={{
width: '100%',
height: '120px',
objectFit: 'cover'
}}
/>
<div style={{ padding: '8px', fontSize: '12px' }}>
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
{image.name}
</div>
<div style={{ color: '#666' }}>
{image.folder}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* Контент вкладки Загрузить */}
{activeTab === 'upload' && (
<div>
<div style={{ marginBottom: '16px' }}>
<input
type="file"
accept="image/*"
onChange={(e) => setUploadFile(e.target.files[0])}
style={{ marginBottom: '12px' }}
/>
<div style={{ fontSize: '14px', color: '#666' }}>
Поддерживаются: JPG, PNG, GIF (макс. 5МБ)
</div>
</div>
{uploadFile && (
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '14px', marginBottom: '8px' }}>
Выбран файл: {uploadFile.name}
</div>
<button
type="button"
onClick={handleFileUpload}
disabled={loading}
style={{
padding: '8px 16px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
{loading ? 'Загрузка...' : 'Загрузить'}
</button>
</div>
)}
</div>
)}
</div>
)}
</div>
);
};
export default ImageEditor;

View File

@@ -0,0 +1,130 @@
// Простой компонент для выбора изображений, который работает с AdminJS
import React from 'react';
const ImageSelector = ({ record, property, onChange }) => {
const currentValue = record.params[property.name] || '';
const openImagePicker = () => {
// Создаем модальное окно с iframe для редактора изображений
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.8);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
border-radius: 8px;
width: 90%;
height: 90%;
position: relative;
`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = `
position: absolute;
top: 10px;
right: 10px;
background: #ff4757;
color: white;
border: none;
border-radius: 50%;
width: 30px;
height: 30px;
cursor: pointer;
z-index: 1;
`;
const iframe = document.createElement('iframe');
iframe.src = `/image-editor.html?field=${property.name}&current=${encodeURIComponent(currentValue)}`;
iframe.style.cssText = `
width: 100%;
height: 100%;
border: none;
border-radius: 8px;
`;
closeBtn.onclick = () => document.body.removeChild(modal);
// Слушаем сообщения от iframe
const handleMessage = (event) => {
if (event.origin !== window.location.origin) return;
if (event.data.type === 'imageSelected' && event.data.targetField === property.name) {
onChange(property.name, event.data.path);
document.body.removeChild(modal);
window.removeEventListener('message', handleMessage);
}
};
window.addEventListener('message', handleMessage);
content.appendChild(closeBtn);
content.appendChild(iframe);
modal.appendChild(content);
document.body.appendChild(modal);
};
return React.createElement('div', null,
React.createElement('label', {
style: { fontWeight: 'bold', marginBottom: '8px', display: 'block' }
}, property.label || property.name),
React.createElement('div', {
style: { display: 'flex', alignItems: 'center', marginBottom: '12px' }
},
React.createElement('input', {
type: 'text',
value: currentValue,
onChange: (e) => onChange(property.name, e.target.value),
placeholder: 'Путь к изображению',
style: {
flex: 1,
padding: '8px 12px',
border: '1px solid #ddd',
borderRadius: '4px',
marginRight: '8px'
}
}),
React.createElement('button', {
type: 'button',
onClick: openImagePicker,
style: {
padding: '8px 16px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}
}, 'Выбрать')
),
currentValue && React.createElement('img', {
src: currentValue,
alt: 'Preview',
style: {
maxWidth: '200px',
maxHeight: '200px',
objectFit: 'cover',
border: '1px solid #ddd',
borderRadius: '4px'
},
onError: (e) => {
e.target.style.display = 'none';
}
})
);
};
export default ImageSelector;

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

View File

@@ -1,9 +1,17 @@
import AdminJS from 'adminjs';
import AdminJSExpress from '@adminjs/express';
import AdminJSSequelize from '@adminjs/sequelize';
import uploadFeature from '@adminjs/upload';
import bcrypt from 'bcryptjs';
import pkg from 'pg';
import { Sequelize, DataTypes } from 'sequelize';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import fs from 'fs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const { Pool } = pkg;
// Регистрируем адаптер Sequelize
@@ -42,6 +50,7 @@ const Routes = sequelize.define('routes', {
price: { type: DataTypes.DECIMAL(10, 2) },
duration: { type: DataTypes.INTEGER },
max_group_size: { type: DataTypes.INTEGER },
image_url: { type: DataTypes.STRING },
is_featured: { type: DataTypes.BOOLEAN, defaultValue: false },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
@@ -60,6 +69,7 @@ const Guides = sequelize.define('guides', {
specialization: { type: DataTypes.ENUM('city', 'mountain', 'fishing', 'general') },
bio: { type: DataTypes.TEXT },
experience: { type: DataTypes.INTEGER },
image_url: { type: DataTypes.STRING },
hourly_rate: { type: DataTypes.DECIMAL(10, 2) },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
@@ -134,10 +144,10 @@ const Admins = sequelize.define('admins', {
name: { type: DataTypes.STRING, allowNull: false },
email: { type: DataTypes.STRING, allowNull: false },
password: { type: DataTypes.STRING, allowNull: false },
role: { type: DataTypes.ENUM('admin', 'manager', 'editor'), defaultValue: 'admin' },
role: { type: DataTypes.STRING, defaultValue: 'admin' },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
last_login: { type: DataTypes.DATE },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'admins'
@@ -187,6 +197,32 @@ const Holidays = sequelize.define('holidays', {
tableName: 'holidays'
});
// Модель для календаря рабочих дней
const GuideWorkingDays = sequelize.define('guide_working_days', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
guide_id: { type: DataTypes.INTEGER, allowNull: false },
work_date: { type: DataTypes.DATEONLY, allowNull: false },
notes: { type: DataTypes.TEXT },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'guide_working_days'
});
// Модель настроек сайта
const SiteSettings = sequelize.define('site_settings', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
setting_key: { type: DataTypes.STRING, allowNull: false, unique: true },
setting_value: { type: DataTypes.TEXT },
setting_type: { type: DataTypes.ENUM('text', 'number', 'boolean', 'json', 'color', 'file'), defaultValue: 'text' },
description: { type: DataTypes.TEXT },
category: { type: DataTypes.STRING, defaultValue: 'general' },
updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'site_settings'
});
// Определение связей между моделями
Guides.hasOne(GuideSchedules, { foreignKey: 'guide_id' });
GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
@@ -194,6 +230,9 @@ GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
Guides.hasMany(Holidays, { foreignKey: 'guide_id' });
Holidays.belongsTo(Guides, { foreignKey: 'guide_id' });
Guides.hasMany(GuideWorkingDays, { foreignKey: 'guide_id' });
GuideWorkingDays.belongsTo(Guides, { foreignKey: 'guide_id' });
Guides.hasMany(Bookings, { foreignKey: 'guide_id' });
Bookings.belongsTo(Guides, { foreignKey: 'guide_id' });
@@ -216,14 +255,65 @@ const getRatingStats = async (targetType, targetId) => {
// Конфигурация AdminJS с ресурсами базы данных
// Конфигурация AdminJS с ресурсами Sequelize
const adminJsOptions = {
locale: {
language: 'ru',
availableLanguages: ['ru', 'en'],
translations: {
en: {
labels: {
routes: 'Routes',
'routes': 'Routes',
'🗺️ Маршруты': 'Routes',
guides: 'Guides',
'guides': 'Guides',
'👥 Гиды': 'Guides',
articles: 'Articles',
'articles': 'Articles',
'📰 Статьи': 'Articles',
bookings: 'Bookings',
'bookings': 'Bookings',
'Заказы': 'Bookings',
reviews: 'Reviews',
'reviews': 'Reviews',
'⭐ Отзывы': 'Reviews',
contact_messages: 'Contact Messages',
'contact_messages': 'Contact Messages',
'💌 Сообщения': 'Contact Messages',
admins: 'Admins',
'admins': 'Admins',
'👨‍💼 Администраторы': 'Admins',
ratings: 'Ratings',
'ratings': 'Ratings',
'🌟 Рейтинги': 'Ratings',
guide_schedules: 'Guide Schedules',
'guide_schedules': 'Guide Schedules',
'📅 Расписание гидов': 'Guide Schedules',
'📅 Расписание Гидов': 'Guide Schedules',
holidays: 'Holidays',
'holidays': 'Holidays',
'🏖️ Выходные дни': 'Holidays',
'🏖️ Выходные Дни': 'Holidays',
site_settings: 'Site Settings',
'site_settings': 'Site Settings',
'⚙️ Настройки сайта': 'Site Settings',
'⚙️ Настройки Сайта': 'Site Settings'
},
pages: {
calendar: 'Calendar',
'style-editor': 'Style Editor',
'media-manager': 'Media Manager'
}
}
}
},
resources: [
{
resource: Routes,
options: {
parent: { name: 'Контент', icon: 'DocumentText' },
parent: { name: 'Контент сайта', icon: 'DocumentText' },
listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'],
editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active'],
showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active', 'created_at', 'updated_at'],
editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active'],
showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active', 'created_at', 'updated_at'],
filterProperties: ['title', 'type', 'is_active'],
properties: {
title: {
@@ -263,6 +353,10 @@ const adminJsOptions = {
type: 'number',
isRequired: true,
},
image_url: {
type: 'string',
description: 'Изображение тура. Кнопка "Выбрать" будет добавлена автоматически'
},
is_featured: { type: 'boolean' },
is_active: { type: 'boolean' },
created_at: {
@@ -277,10 +371,11 @@ const adminJsOptions = {
{
resource: Guides,
options: {
parent: { name: 'Персонал', icon: 'Users' },
parent: { name: 'Персонал и гиды', icon: 'Users' },
id: 'guides',
listProperties: ['id', 'name', 'email', 'specialization', 'experience', 'hourly_rate', 'is_active'],
editProperties: ['name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'hourly_rate', 'is_active'],
showProperties: ['id', 'name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'hourly_rate', 'is_active', 'created_at'],
editProperties: ['name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'image_url', 'hourly_rate', 'is_active'],
showProperties: ['id', 'name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'image_url', 'hourly_rate', 'is_active', 'created_at'],
filterProperties: ['name', 'specialization', 'is_active'],
properties: {
name: {
@@ -309,6 +404,10 @@ const adminJsOptions = {
type: 'number',
description: 'Опыт работы в годах',
},
image_url: {
type: 'string',
description: 'Фотография гида. Кнопка "Выбрать" будет добавлена автоматически'
},
hourly_rate: {
type: 'number',
description: 'Ставка за час в вонах',
@@ -323,9 +422,9 @@ const adminJsOptions = {
{
resource: Articles,
options: {
parent: { name: 'Контент', icon: 'DocumentText' },
parent: { name: 'Контент сайта', icon: 'DocumentText' },
listProperties: ['id', 'title', 'category', 'is_published', 'views', 'created_at'],
editProperties: ['title', 'excerpt', 'content', 'category', 'is_published'],
editProperties: ['title', 'excerpt', 'content', 'category', 'image_url', 'is_published'],
showProperties: ['id', 'title', 'excerpt', 'content', 'category', 'is_published', 'views', 'created_at', 'updated_at'],
filterProperties: ['title', 'category', 'is_published'],
properties: {
@@ -350,6 +449,10 @@ const adminJsOptions = {
{ value: 'history', label: 'История' }
],
},
image_url: {
type: 'string',
description: 'Изображение статьи. Кнопка "Выбрать" будет добавлена автоматически'
},
is_published: { type: 'boolean' },
views: {
type: 'number',
@@ -367,7 +470,7 @@ const adminJsOptions = {
{
resource: Bookings,
options: {
parent: { name: 'Заказы', icon: 'ShoppingCart' },
parent: { name: 'Бронирования', icon: 'ShoppingCart' },
listProperties: ['id', 'customer_name', 'customer_email', 'preferred_date', 'status', 'total_price', 'created_at'],
editProperties: ['customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes'],
showProperties: ['id', 'customer_name', 'customer_email', 'customer_phone', 'preferred_date', 'group_size', 'status', 'total_price', 'notes', 'created_at'],
@@ -412,7 +515,7 @@ const adminJsOptions = {
{
resource: Reviews,
options: {
parent: { name: 'Отзывы', icon: 'Star' },
parent: { name: 'Отзывы и рейтинги', icon: 'Star' },
listProperties: ['id', 'customer_name', 'rating', 'is_approved', 'created_at'],
editProperties: ['customer_name', 'customer_email', 'rating', 'comment', 'is_approved'],
showProperties: ['id', 'customer_name', 'customer_email', 'rating', 'comment', 'is_approved', 'created_at'],
@@ -444,7 +547,7 @@ const adminJsOptions = {
{
resource: ContactMessages,
options: {
parent: { name: 'Сообщения', icon: 'Email' },
parent: { name: 'Администрирование', icon: 'Settings' },
listProperties: ['id', 'name', 'email', 'subject', 'status', 'created_at'],
editProperties: ['name', 'email', 'phone', 'subject', 'message', 'status'],
showProperties: ['id', 'name', 'email', 'phone', 'subject', 'message', 'status', 'created_at'],
@@ -491,6 +594,7 @@ const adminJsOptions = {
resource: Admins,
options: {
parent: { name: 'Администрирование', icon: 'Settings' },
id: 'admins',
listProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'created_at'],
editProperties: ['username', 'name', 'email', 'role', 'is_active'],
showProperties: ['id', 'username', 'name', 'email', 'role', 'is_active', 'last_login', 'created_at'],
@@ -532,7 +636,7 @@ const adminJsOptions = {
{
resource: Ratings,
options: {
parent: { name: 'Система рейтингов', icon: 'Star' },
parent: { name: 'Отзывы и рейтинги', icon: 'Star' },
listProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
showProperties: ['id', 'target_type', 'target_id', 'rating', 'user_ip', 'created_at'],
filterProperties: ['target_type', 'target_id', 'rating'],
@@ -566,7 +670,7 @@ const adminJsOptions = {
{
resource: GuideSchedules,
options: {
parent: { name: 'Управление гидами', icon: 'Calendar' },
parent: { name: 'Персонал и гиды', icon: 'Users' },
listProperties: ['id', 'guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time'],
editProperties: ['guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time'],
showProperties: ['id', 'guide_id', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday', 'start_time', 'end_time', 'created_at', 'updated_at'],
@@ -598,12 +702,42 @@ const adminJsOptions = {
isVisible: { list: false, filter: false, show: true, edit: false },
}
},
actions: {
list: {
after: async (response) => {
if (response.records) {
response.meta = {
...response.meta,
customHeader: `
<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,
options: {
parent: { name: 'Управление гидами', icon: 'Calendar' },
parent: { name: 'Персонал и гиды', icon: 'Users' },
listProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
editProperties: ['date', 'title', 'type', 'guide_id'],
showProperties: ['id', 'date', 'title', 'type', 'guide_id', 'created_at'],
@@ -633,6 +767,109 @@ const adminJsOptions = {
}
},
}
},
{
resource: GuideWorkingDays,
options: {
parent: { name: 'Персонал и гиды', icon: 'Users' },
listProperties: ['id', 'guide_id', 'work_date', 'notes', 'created_at'],
editProperties: ['guide_id', 'work_date', 'notes'],
showProperties: ['id', 'guide_id', 'work_date', 'notes', 'created_at'],
filterProperties: ['guide_id', 'work_date'],
properties: {
guide_id: {
isTitle: true,
isRequired: true,
},
work_date: {
type: 'date',
isRequired: true,
},
notes: {
type: 'textarea',
description: 'Дополнительные заметки для этого рабочего дня'
},
created_at: {
isVisible: { list: true, filter: false, show: true, edit: false },
}
},
actions: {
list: {
isVisible: true,
after: async (response) => {
// Добавляем информацию о календаре
if (response.records) {
response.meta = response.meta || {};
response.meta.total = response.records.length;
response.meta.notice = {
message: '💡 Для удобного управления расписанием используйте полный календарь',
type: 'info'
};
}
return response;
}
},
new: {
isVisible: true
},
edit: { isVisible: true },
delete: { isVisible: true },
show: { isVisible: true }
}
}
},
{
resource: SiteSettings,
options: {
parent: { name: 'Администрирование', icon: 'Settings' },
id: 'site_settings',
listProperties: ['id', 'setting_key', 'setting_value', 'category', 'updated_at'],
editProperties: ['setting_key', 'setting_value', 'setting_type', 'description', 'category'],
showProperties: ['id', 'setting_key', 'setting_value', 'setting_type', 'description', 'category', 'updated_at'],
filterProperties: ['setting_key', 'category', 'setting_type'],
properties: {
setting_key: {
isTitle: true,
isRequired: true,
description: 'Уникальный ключ настройки (например: primary_color, hero_background_url)'
},
setting_value: {
type: 'textarea',
isRequired: true,
description: 'Значение настройки (цвет в HEX, URL изображения, текст и т.д.)'
},
setting_type: {
availableValues: [
{ value: 'text', label: 'Текст' },
{ value: 'number', label: 'Число' },
{ value: 'boolean', label: 'Да/Нет' },
{ value: 'json', label: 'JSON' },
{ value: 'color', label: 'Цвет (HEX)' },
{ value: 'file', label: 'Файл/URL' }
],
isRequired: true
},
description: {
type: 'textarea',
description: 'Описание назначения этой настройки'
},
category: {
availableValues: [
{ value: 'general', label: 'Общие' },
{ value: 'theme', label: 'Тема и стили' },
{ value: 'colors', label: 'Цвета' },
{ value: 'typography', label: 'Типографика' },
{ value: 'images', label: 'Изображения' },
{ value: 'effects', label: 'Эффекты' },
{ value: 'layout', label: 'Макет' }
],
defaultValue: 'general'
},
updated_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
}
],
rootPath: '/admin',
@@ -658,11 +895,166 @@ const adminJsOptions = {
},
},
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: {
styles: ['/css/admin-custom.css'],
scripts: [
'/js/admin-image-selector-fixed.js',
'/js/universal-media-manager-integration.js'
]
}
};
// Создаем экземпляр AdminJS
// Создаем экземпляр AdminJS с componentLoader
// Создание AdminJS с конфигурацией
const adminJs = new AdminJS(adminJsOptions);
// Настраиваем аутентификацию
@@ -705,13 +1097,37 @@ const router = AdminJSExpress.buildAuthenticatedRouter(adminJs, {
},
cookiePassword: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024'
}, null, {
resave: false,
saveUninitialized: false,
resave: true,
saveUninitialized: true,
secret: process.env.SESSION_SECRET || 'korea-tourism-secret-key-2024',
name: 'adminjs-session',
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 часа
secure: false, // Отключаем secure для development
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 дней
sameSite: 'lax'
},
rolling: true // Продлеваем сессию при каждом запросе
});
// Добавляем дополнительные роуты после аутентификации
router.get('/calendar-view', async (request, response) => {
try {
const calendarPath = path.join(__dirname, '../../public/admin-calendar-component.html');
const calendarHtml = fs.readFileSync(calendarPath, 'utf8');
response.send(calendarHtml);
} catch (error) {
response.status(500).send('Error loading calendar');
}
});
router.get('/schedule-manager', async (request, response) => {
try {
const schedulePath = path.join(__dirname, '../../public/schedule-manager.html');
const scheduleHtml = fs.readFileSync(schedulePath, 'utf8');
response.send(scheduleHtml);
} catch (error) {
response.status(500).send('Error loading schedule manager');
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,190 @@
import db from '../config/database.js';
class SiteSettingsHelper {
static cache = new Map();
static lastUpdate = null;
static cacheTimeout = 5 * 60 * 1000; // 5 минут
/**
* Получить все настройки сайта с кешированием
*/
static async getAllSettings() {
const now = Date.now();
// Проверяем кеш
if (this.lastUpdate && (now - this.lastUpdate) < this.cacheTimeout && this.cache.size > 0) {
return Object.fromEntries(this.cache);
}
try {
const result = await db.query('SELECT setting_key, setting_value, setting_type FROM site_settings ORDER BY category, setting_key');
// Очищаем кеш и заполняем новыми данными
this.cache.clear();
for (const row of result.rows) {
let value = row.setting_value;
// Преобразуем значение в зависимости от типа
switch (row.setting_type) {
case 'number':
value = parseFloat(value) || 0;
break;
case 'boolean':
value = value === 'true' || value === '1';
break;
case 'json':
try {
value = JSON.parse(value);
} catch {
value = {};
}
break;
}
this.cache.set(row.setting_key, value);
}
this.lastUpdate = now;
return Object.fromEntries(this.cache);
} catch (error) {
console.error('Error loading site settings:', error);
return {};
}
}
/**
* Получить настройку по ключу
*/
static async getSetting(key, defaultValue = null) {
const settings = await this.getAllSettings();
return settings[key] !== undefined ? settings[key] : defaultValue;
}
/**
* Установить настройку
*/
static async setSetting(key, value, type = 'text', description = '', category = 'general') {
try {
await db.query(`
INSERT INTO site_settings (setting_key, setting_value, setting_type, description, category, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (setting_key)
DO UPDATE SET
setting_value = EXCLUDED.setting_value,
setting_type = EXCLUDED.setting_type,
description = EXCLUDED.description,
category = EXCLUDED.category,
updated_at = NOW()
`, [key, String(value), type, description, category]);
// Обновляем кеш
this.cache.set(key, value);
return true;
} catch (error) {
console.error('Error setting site setting:', error);
return false;
}
}
/**
* Получить настройки по категории
*/
static async getSettingsByCategory(category) {
try {
const result = await db.query(
'SELECT setting_key, setting_value, setting_type FROM site_settings WHERE category = $1 ORDER BY setting_key',
[category]
);
const settings = {};
for (const row of result.rows) {
let value = row.setting_value;
switch (row.setting_type) {
case 'number':
value = parseFloat(value) || 0;
break;
case 'boolean':
value = value === 'true' || value === '1';
break;
case 'json':
try {
value = JSON.parse(value);
} catch {
value = {};
}
break;
}
settings[row.setting_key] = value;
}
return settings;
} catch (error) {
console.error('Error loading settings by category:', error);
return {};
}
}
/**
* Генерировать CSS переменные из настроек
*/
static async generateCSSVariables() {
const settings = await this.getAllSettings();
let css = ':root {\n';
// Цвета
if (settings.primary_color) css += ` --primary-color: ${settings.primary_color};\n`;
if (settings.secondary_color) css += ` --secondary-color: ${settings.secondary_color};\n`;
if (settings.accent_color) css += ` --accent-color: ${settings.accent_color};\n`;
if (settings.text_color) css += ` --text-color: ${settings.text_color};\n`;
if (settings.background_color) css += ` --background-color: ${settings.background_color};\n`;
if (settings.card_background) css += ` --card-background: ${settings.card_background};\n`;
// Типографика
if (settings.font_family_primary) css += ` --font-family-primary: ${settings.font_family_primary};\n`;
if (settings.font_family_display) css += ` --font-family-display: ${settings.font_family_display};\n`;
if (settings.font_size_base) css += ` --font-size-base: ${settings.font_size_base}px;\n`;
if (settings.line_height_base) css += ` --line-height-base: ${settings.line_height_base};\n`;
// Эффекты
if (settings.hero_overlay_opacity) css += ` --hero-overlay-opacity: ${settings.hero_overlay_opacity};\n`;
if (settings.hero_overlay_color) css += ` --hero-overlay-color: ${settings.hero_overlay_color};\n`;
if (settings.card_shadow) css += ` --card-shadow: ${settings.card_shadow};\n`;
if (settings.border_radius) css += ` --border-radius: ${settings.border_radius}px;\n`;
if (settings.blur_effect) css += ` --blur-effect: ${settings.blur_effect}px;\n`;
// Макет
if (settings.hero_height_desktop) css += ` --hero-height-desktop: ${settings.hero_height_desktop}vh;\n`;
if (settings.hero_height_mobile) css += ` --hero-height-mobile: ${settings.hero_height_mobile}vh;\n`;
if (settings.compact_hero_height) css += ` --compact-hero-height: ${settings.compact_hero_height}vh;\n`;
if (settings.container_max_width) css += ` --container-max-width: ${settings.container_max_width}px;\n`;
if (settings.navbar_height) css += ` --navbar-height: ${settings.navbar_height}px;\n`;
// Анимации
if (settings.animation_duration) css += ` --animation-duration: ${settings.animation_duration}s;\n`;
css += '}\n';
// Добавляем пользовательский CSS
if (settings.custom_css) {
css += '\n/* Пользовательский CSS */\n' + settings.custom_css + '\n';
}
return css;
}
/**
* Очистить кеш настроек
*/
static clearCache() {
this.cache.clear();
this.lastUpdate = null;
}
}
export default SiteSettingsHelper;

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

View File

@@ -101,14 +101,63 @@ router.post('/booking', async (req, res) => {
guide_id
} = req.body;
// Проверяем доступность гида на указанную дату
const searchDate = new Date(preferred_date);
const dayOfWeek = searchDate.getDay();
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
const dayName = dayNames[dayOfWeek];
// Проверяем расписание гида
const scheduleCheck = await db.query(`
SELECT * FROM guide_schedules
WHERE guide_id = $1 AND ${dayName} = true
`, [guide_id]);
if (scheduleCheck.rows.length === 0) {
return res.status(400).json({
success: false,
message: 'Гид не работает в этот день недели'
});
}
// Проверяем выходные дни
const holidayCheck = await db.query(`
SELECT id FROM holidays
WHERE guide_id = $1 AND date = $2
`, [guide_id, preferred_date]);
if (holidayCheck.rows.length > 0) {
return res.status(400).json({
success: false,
message: 'У гида выходной в этот день'
});
}
// Проверяем количество существующих бронирований
const existingBookings = await db.query(`
SELECT COUNT(*) as count
FROM bookings
WHERE guide_id = $1
AND preferred_date = $2
AND status != 'cancelled'
`, [guide_id, preferred_date]);
if (parseInt(existingBookings.rows[0].count) >= 3) {
return res.status(400).json({
success: false,
message: 'На эту дату у гида больше нет свободных мест'
});
}
// Создаем бронирование
const booking = await db.query(`
INSERT INTO bookings (
route_id, guide_id, customer_name, customer_email,
customer_phone, preferred_date, group_size,
special_requirements, status
special_requirements, status, booking_date, booking_time
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending')
RETURNING id
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $6, CURRENT_TIME)
RETURNING id, preferred_date
`, [
route_id, guide_id, customer_name, customer_email,
customer_phone, preferred_date, group_size,
@@ -117,14 +166,14 @@ router.post('/booking', async (req, res) => {
res.json({
success: true,
message: 'Booking request submitted successfully! We will contact you soon.',
message: 'Заявка на бронирование успешно отправлена! Мы свяжемся с вами в ближайшее время.',
booking_id: booking.rows[0].id
});
} catch (error) {
console.error('API error submitting booking:', error);
res.status(500).json({
success: false,
message: 'Error submitting booking request'
message: 'Ошибка при отправке заявки на бронирование'
});
}
});
@@ -239,4 +288,289 @@ router.post('/reviews', async (req, res) => {
}
});
// Statistics endpoint for admin dashboard
router.get('/stats', async (req, res) => {
try {
const [routes, guides, articles, bookings] = await Promise.all([
db.query('SELECT COUNT(*) FROM routes WHERE is_active = true'),
db.query('SELECT COUNT(*) FROM guides WHERE is_active = true'),
db.query('SELECT COUNT(*) FROM articles WHERE is_active = true'),
db.query('SELECT COUNT(*) FROM bookings')
]);
res.json({
success: true,
routes: parseInt(routes.rows[0].count),
guides: parseInt(guides.rows[0].count),
articles: parseInt(articles.rows[0].count),
bookings: parseInt(bookings.rows[0].count)
});
} catch (error) {
console.error('API error loading stats:', error);
res.status(500).json({
success: false,
message: 'Ошибка загрузки статистики'
});
}
});
// API для календаря гидов
router.get('/guides', async (req, res) => {
try {
const guides = await db.query(`
SELECT id, name, email, phone, languages, specializations, bio
FROM guides
WHERE is_active = true
ORDER BY name
`);
res.json(guides.rows);
} catch (error) {
console.error('Ошибка получения гидов:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
router.get('/guide-schedules', async (req, res) => {
try {
const schedules = await db.query(`
SELECT * FROM guide_schedules
`);
res.json(schedules.rows);
} catch (error) {
console.error('Ошибка получения расписаний:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
router.get('/holidays', async (req, res) => {
try {
const holidays = await db.query(`
SELECT * FROM holidays
WHERE date >= CURRENT_DATE - INTERVAL '30 days'
AND date <= CURRENT_DATE + INTERVAL '60 days'
ORDER BY date
`);
res.json(holidays.rows);
} catch (error) {
console.error('Ошибка получения выходных дней:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
router.get('/bookings', async (req, res) => {
try {
const bookings = await db.query(`
SELECT b.*, g.name as guide_name, r.title as route_title
FROM bookings b
LEFT JOIN guides g ON b.guide_id = g.id
LEFT JOIN routes r ON b.route_id = r.id
WHERE b.preferred_date >= CURRENT_DATE - INTERVAL '7 days'
AND b.preferred_date <= CURRENT_DATE + INTERVAL '60 days'
ORDER BY b.preferred_date, b.created_at
`);
res.json(bookings.rows);
} catch (error) {
console.error('Ошибка получения бронирований:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Обновленный поиск доступных туров с учётом графика гидов
router.get('/search-available', async (req, res) => {
try {
const { destination, date, people } = req.query;
if (!date) {
return res.status(400).json({ error: 'Дата обязательна для поиска' });
}
// Определяем день недели для указанной даты
const searchDate = new Date(date);
const dayOfWeek = searchDate.getDay();
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
const dayName = dayNames[dayOfWeek];
let query = `
SELECT DISTINCT
r.id, r.title, r.description, r.type, r.duration,
r.price, r.difficulty_level, r.max_group_size, r.image_url,
g.id as guide_id, g.name as guide_name, g.languages as guide_languages,
gs.start_time, gs.end_time
FROM routes r
INNER JOIN guides g ON g.is_active = true
INNER JOIN guide_schedules gs ON g.id = gs.guide_id
LEFT JOIN holidays h ON (g.id = h.guide_id AND h.date = $1)
WHERE r.is_active = true
AND gs.${dayName} = true
AND h.id IS NULL
`;
const params = [date];
if (destination) {
query += ` AND (r.title ILIKE $${params.length + 1} OR r.description ILIKE $${params.length + 1})`;
params.push(`%${destination}%`);
}
// Проверяем размер группы
if (people) {
query += ` AND r.max_group_size >= $${params.length + 1}`;
params.push(parseInt(people));
}
query += ` ORDER BY r.title`;
const results = await db.query(query, params);
// Проверяем занятость гидов на указанную дату
const availableRoutes = [];
for (const route of results.rows) {
// Проверяем существующие бронирования для гида на эту дату
const existingBookings = await db.query(`
SELECT COUNT(*) as booking_count
FROM bookings
WHERE guide_id = $1
AND preferred_date = $2
AND status != 'cancelled'
`, [route.guide_id, date]);
const bookingCount = parseInt(existingBookings.rows[0]?.booking_count || 0);
// Считаем, что гид может вести до 3 групп в день
if (bookingCount < 3) {
availableRoutes.push({
...route,
guide_available: true,
available_slots: 3 - bookingCount
});
}
}
res.json({
success: true,
data: availableRoutes
});
} catch (error) {
console.error('Ошибка поиска:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Get tours available on specific date
router.get('/tours-by-date', async (req, res) => {
try {
const { date } = req.query;
if (!date) {
return res.status(400).json({
success: false,
error: 'Дата не указана'
});
}
// Получаем гидов, которые работают в указанную дату
const workingGuidesQuery = `
SELECT gwd.guide_id, g.name as guide_name, g.specialization, g.hourly_rate
FROM guide_working_days gwd
JOIN guides g ON g.id = gwd.guide_id
WHERE gwd.work_date = $1 AND g.is_active = true
`;
const workingGuides = await db.query(workingGuidesQuery, [date]);
if (workingGuides.rows.length === 0) {
return res.json({
success: true,
data: [],
message: 'В этот день нет доступных гидов'
});
}
// Получаем туры для работающих гидов
const routesQuery = `
SELECT DISTINCT r.*, g.name as guide_name, g.specialization as guide_specialization,
g.hourly_rate, gwd.notes as guide_notes
FROM routes r
JOIN guides g ON r.guide_id = g.id
JOIN guide_working_days gwd ON g.id = gwd.guide_id
WHERE gwd.work_date = $1
AND r.is_active = true
AND g.is_active = true
AND (r.type = g.specialization OR r.guide_id = g.id)
ORDER BY r.type, r.title
`;
const routes = await db.query(routesQuery, [date]);
res.json({
success: true,
data: routes.rows,
working_guides: workingGuides.rows,
date: date
});
} catch (error) {
console.error('Ошибка получения туров по дате:', error);
res.status(500).json({
success: false,
error: 'Внутренняя ошибка сервера'
});
}
});
// Get calendar data for tours
router.get('/tours-calendar', async (req, res) => {
try {
const { month, year } = req.query;
const currentYear = year || new Date().getFullYear();
const currentMonth = month || (new Date().getMonth() + 1);
// Получаем все рабочие дни в указанном месяце
const startDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-01`;
const endDate = `${currentYear}-${String(currentMonth).padStart(2, '0')}-31`;
const calendarQuery = `
SELECT
gwd.work_date,
COUNT(DISTINCT gwd.guide_id) as guides_count,
COUNT(DISTINCT r.id) as routes_count,
JSON_AGG(DISTINCT jsonb_build_object(
'guide_id', g.id,
'guide_name', g.name,
'specialization', g.specialization,
'route_count', (
SELECT COUNT(*)
FROM routes r2
WHERE r2.guide_id = g.id AND r2.is_active = true
)
)) as guides_data
FROM guide_working_days gwd
JOIN guides g ON g.id = gwd.guide_id
LEFT JOIN routes r ON (r.guide_id = g.id OR r.type = g.specialization) AND r.is_active = true
WHERE gwd.work_date >= $1 AND gwd.work_date <= $2 AND g.is_active = true
GROUP BY gwd.work_date
ORDER BY gwd.work_date
`;
const calendarData = await db.query(calendarQuery, [startDate, endDate]);
res.json({
success: true,
data: calendarData.rows,
month: currentMonth,
year: currentYear
});
} catch (error) {
console.error('Ошибка получения календарных данных:', error);
res.status(500).json({
success: false,
error: 'Внутренняя ошибка сервера'
});
}
});
export default router;

653
src/routes/crud.js Normal file
View File

@@ -0,0 +1,653 @@
import express from 'express';
import { Op } from 'sequelize';
import db from '../config/database.js';
const router = express.Router();
// Middleware для логирования
const logRequest = (entity) => (req, res, next) => {
console.log(`${new Date().toISOString()} - ${req.method} /api/crud/${entity} - ${JSON.stringify(req.body || req.query).slice(0, 100)}`);
next();
};
// Общая функция для обработки ошибок
const handleError = (res, error, operation = 'operation') => {
console.error(`CRUD ${operation} error:`, error);
res.status(500).json({
success: false,
error: `Ошибка ${operation}: ${error.message}`
});
};
// Общая функция для валидации ID
const validateId = (id) => {
const numId = parseInt(id);
if (isNaN(numId) || numId < 1) {
throw new Error('Некорректный ID');
}
return numId;
};
/**
* ROUTES CRUD
*/
// GET /api/crud/routes - получить все маршруты
router.get('/routes', logRequest('routes'), async (req, res) => {
try {
const {
page = 1,
limit = 10,
type,
difficulty_level,
is_active = 'all',
search,
sort_by = 'id',
sort_order = 'ASC'
} = req.query;
const offset = (parseInt(page) - 1) * parseInt(limit);
const where = {};
// Фильтры
if (type) where.type = type;
if (difficulty_level) where.difficulty_level = difficulty_level;
if (is_active !== 'all') where.is_active = is_active === 'true';
if (search) {
where[Op.or] = [
{ title: { [Op.iLike]: `%${search}%` } },
{ description: { [Op.iLike]: `%${search}%` } }
];
}
const order = [[sort_by, sort_order.toUpperCase()]];
const result = await db.query(`
SELECT
id, title, description, type, difficulty_level,
price, duration, max_group_size, image_url,
is_featured, is_active, created_at, updated_at
FROM routes
WHERE ($1::text IS NULL OR type = $1)
AND ($2::text IS NULL OR difficulty_level = $2)
AND ($3::boolean IS NULL OR is_active = $3)
AND ($4::text IS NULL OR (title ILIKE $4 OR description ILIKE $4))
ORDER BY ${sort_by} ${sort_order.toUpperCase()}
LIMIT $5 OFFSET $6
`, [
type || null,
difficulty_level || null,
is_active === 'all' ? null : (is_active === 'true'),
search ? `%${search}%` : null,
parseInt(limit),
offset
]);
const countResult = await db.query(`
SELECT COUNT(*)
FROM routes
WHERE ($1::text IS NULL OR type = $1)
AND ($2::text IS NULL OR difficulty_level = $2)
AND ($3::boolean IS NULL OR is_active = $3)
AND ($4::text IS NULL OR (title ILIKE $4 OR description ILIKE $4))
`, [
type || null,
difficulty_level || null,
is_active === 'all' ? null : (is_active === 'true'),
search ? `%${search}%` : null
]);
const total = parseInt(countResult.rows[0].count);
const totalPages = Math.ceil(total / parseInt(limit));
res.json({
success: true,
data: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages
}
});
} catch (error) {
handleError(res, error, 'получения маршрутов');
}
});
// GET /api/crud/routes/:id - получить маршрут по ID
router.get('/routes/:id', logRequest('routes'), async (req, res) => {
try {
const id = validateId(req.params.id);
const result = await db.query(`
SELECT * FROM routes WHERE id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Маршрут не найден' });
}
res.json({ success: true, data: result.rows[0] });
} catch (error) {
handleError(res, error, 'получения маршрута');
}
});
// POST /api/crud/routes - создать новый маршрут
router.post('/routes', logRequest('routes'), async (req, res) => {
try {
const {
title, description, content, type, difficulty_level,
price, duration, max_group_size, image_url,
is_featured = false, is_active = true
} = req.body;
// Валидация обязательных полей
if (!title || !type || !price || !duration || !max_group_size) {
return res.status(400).json({
success: false,
error: 'Отсутствуют обязательные поля: title, type, price, duration, max_group_size'
});
}
const result = await db.query(`
INSERT INTO routes (
title, description, content, type, difficulty_level,
price, duration, max_group_size, image_url,
is_featured, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`, [
title, description, content, type, difficulty_level,
price, duration, max_group_size, image_url,
is_featured, is_active
]);
res.status(201).json({ success: true, data: result.rows[0] });
} catch (error) {
handleError(res, error, 'создания маршрута');
}
});
// PUT /api/crud/routes/:id - обновить маршрут
router.put('/routes/:id', logRequest('routes'), async (req, res) => {
try {
const id = validateId(req.params.id);
const {
title, description, content, type, difficulty_level,
price, duration, max_group_size, image_url,
is_featured, is_active
} = req.body;
const result = await db.query(`
UPDATE routes SET
title = COALESCE($1, title),
description = COALESCE($2, description),
content = COALESCE($3, content),
type = COALESCE($4, type),
difficulty_level = COALESCE($5, difficulty_level),
price = COALESCE($6, price),
duration = COALESCE($7, duration),
max_group_size = COALESCE($8, max_group_size),
image_url = COALESCE($9, image_url),
is_featured = COALESCE($10, is_featured),
is_active = COALESCE($11, is_active),
updated_at = CURRENT_TIMESTAMP
WHERE id = $12
RETURNING *
`, [
title, description, content, type, difficulty_level,
price, duration, max_group_size, image_url,
is_featured, is_active, id
]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Маршрут не найден' });
}
res.json({ success: true, data: result.rows[0] });
} catch (error) {
handleError(res, error, 'обновления маршрута');
}
});
// DELETE /api/crud/routes/:id - удалить маршрут
router.delete('/routes/:id', logRequest('routes'), async (req, res) => {
try {
const id = validateId(req.params.id);
const result = await db.query(`
DELETE FROM routes WHERE id = $1 RETURNING id, title
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Маршрут не найден' });
}
res.json({
success: true,
message: `Маршрут "${result.rows[0].title}" успешно удален`,
data: { id: result.rows[0].id }
});
} catch (error) {
handleError(res, error, 'удаления маршрута');
}
});
/**
* GUIDES CRUD
*/
// GET /api/crud/guides - получить всех гидов
router.get('/guides', logRequest('guides'), async (req, res) => {
try {
const {
page = 1,
limit = 10,
specialization,
is_active = 'all',
search,
sort_by = 'id',
sort_order = 'ASC'
} = req.query;
const offset = (parseInt(page) - 1) * parseInt(limit);
const where = {};
if (specialization) where.specialization = specialization;
if (is_active !== 'all') where.is_active = is_active === 'true';
const result = await db.query(`
SELECT
id, name, email, phone, languages, specialization,
bio, experience, image_url, hourly_rate,
is_active, created_at, updated_at
FROM guides
WHERE ($1::text IS NULL OR specialization = $1)
AND ($2::boolean IS NULL OR is_active = $2)
AND ($3::text IS NULL OR (name ILIKE $3 OR email ILIKE $3 OR bio ILIKE $3))
ORDER BY ${sort_by} ${sort_order.toUpperCase()}
LIMIT $4 OFFSET $5
`, [
specialization || null,
is_active === 'all' ? null : (is_active === 'true'),
search ? `%${search}%` : null,
parseInt(limit),
offset
]);
const countResult = await db.query(`
SELECT COUNT(*)
FROM guides
WHERE ($1::text IS NULL OR specialization = $1)
AND ($2::boolean IS NULL OR is_active = $2)
AND ($3::text IS NULL OR (name ILIKE $3 OR email ILIKE $3 OR bio ILIKE $3))
`, [
specialization || null,
is_active === 'all' ? null : (is_active === 'true'),
search ? `%${search}%` : null
]);
const total = parseInt(countResult.rows[0].count);
const totalPages = Math.ceil(total / parseInt(limit));
res.json({
success: true,
data: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages
}
});
} catch (error) {
handleError(res, error, 'получения гидов');
}
});
// GET /api/crud/guides/:id - получить гида по ID
router.get('/guides/:id', logRequest('guides'), async (req, res) => {
try {
const id = validateId(req.params.id);
const result = await db.query(`
SELECT * FROM guides WHERE id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Гид не найден' });
}
res.json({ success: true, data: result.rows[0] });
} catch (error) {
handleError(res, error, 'получения гида');
}
});
// POST /api/crud/guides - создать нового гида
router.post('/guides', logRequest('guides'), async (req, res) => {
try {
const {
name, email, phone, languages, specialization,
bio, experience, image_url, hourly_rate,
is_active = true
} = req.body;
// Валидация обязательных полей
if (!name || !email || !specialization) {
return res.status(400).json({
success: false,
error: 'Отсутствуют обязательные поля: name, email, specialization'
});
}
// Преобразовываем languages в массив если передана строка
let processedLanguages = languages;
if (typeof languages === 'string') {
processedLanguages = languages.split(',').map(lang => lang.trim());
}
const result = await db.query(`
INSERT INTO guides (
name, email, phone, languages, specialization,
bio, experience, image_url, hourly_rate, is_active
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *
`, [
name, email, phone, processedLanguages, specialization,
bio, experience, image_url, hourly_rate, is_active
]);
res.status(201).json({ success: true, data: result.rows[0] });
} catch (error) {
handleError(res, error, 'создания гида');
}
});
// PUT /api/crud/guides/:id - обновить гида
router.put('/guides/:id', logRequest('guides'), async (req, res) => {
try {
const id = validateId(req.params.id);
const {
name, email, phone, languages, specialization,
bio, experience, image_url, hourly_rate, is_active
} = req.body;
// Преобразовываем languages в массив если передана строка
let processedLanguages = languages;
if (typeof languages === 'string') {
processedLanguages = languages.split(',').map(lang => lang.trim());
}
const result = await db.query(`
UPDATE guides SET
name = COALESCE($1, name),
email = COALESCE($2, email),
phone = COALESCE($3, phone),
languages = COALESCE($4, languages),
specialization = COALESCE($5, specialization),
bio = COALESCE($6, bio),
experience = COALESCE($7, experience),
image_url = COALESCE($8, image_url),
hourly_rate = COALESCE($9, hourly_rate),
is_active = COALESCE($10, is_active),
updated_at = CURRENT_TIMESTAMP
WHERE id = $11
RETURNING *
`, [
name, email, phone, processedLanguages, specialization,
bio, experience, image_url, hourly_rate, is_active, id
]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Гид не найден' });
}
res.json({ success: true, data: result.rows[0] });
} catch (error) {
handleError(res, error, 'обновления гида');
}
});
// DELETE /api/crud/guides/:id - удалить гида
router.delete('/guides/:id', logRequest('guides'), async (req, res) => {
try {
const id = validateId(req.params.id);
const result = await db.query(`
DELETE FROM guides WHERE id = $1 RETURNING id, name
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Гид не найден' });
}
res.json({
success: true,
message: `Гид "${result.rows[0].name}" успешно удален`,
data: { id: result.rows[0].id }
});
} catch (error) {
handleError(res, error, 'удаления гида');
}
});
/**
* ARTICLES CRUD
*/
// GET /api/crud/articles - получить все статьи
router.get('/articles', logRequest('articles'), async (req, res) => {
try {
const {
page = 1,
limit = 10,
category,
is_published = 'all',
search,
sort_by = 'id',
sort_order = 'DESC'
} = req.query;
const offset = (parseInt(page) - 1) * parseInt(limit);
const result = await db.query(`
SELECT
id, title, excerpt, content, category,
image_url, author_id, is_published,
created_at, updated_at
FROM articles
WHERE ($1::text IS NULL OR category = $1)
AND ($2::boolean IS NULL OR is_published = $2)
AND ($3::text IS NULL OR (title ILIKE $3 OR excerpt ILIKE $3 OR content ILIKE $3))
ORDER BY ${sort_by} ${sort_order.toUpperCase()}
LIMIT $4 OFFSET $5
`, [
category || null,
is_published === 'all' ? null : (is_published === 'true'),
search ? `%${search}%` : null,
parseInt(limit),
offset
]);
const countResult = await db.query(`
SELECT COUNT(*)
FROM articles
WHERE ($1::text IS NULL OR category = $1)
AND ($2::boolean IS NULL OR is_published = $2)
AND ($3::text IS NULL OR (title ILIKE $3 OR excerpt ILIKE $3 OR content ILIKE $3))
`, [
category || null,
is_published === 'all' ? null : (is_published === 'true'),
search ? `%${search}%` : null
]);
const total = parseInt(countResult.rows[0].count);
const totalPages = Math.ceil(total / parseInt(limit));
res.json({
success: true,
data: result.rows,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages
}
});
} catch (error) {
handleError(res, error, 'получения статей');
}
});
// GET /api/crud/articles/:id - получить статью по ID
router.get('/articles/:id', logRequest('articles'), async (req, res) => {
try {
const id = validateId(req.params.id);
const result = await db.query(`
SELECT
id, title, excerpt, content, category, image_url,
author_id, is_published, created_at, updated_at
FROM articles
WHERE id = $1
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({
success: false,
error: 'Статья не найдена'
});
}
res.json({
success: true,
data: result.rows[0]
});
} catch (error) {
handleError(res, error, 'получения статьи');
}
});
// POST /api/crud/articles - создать новую статью
router.post('/articles', logRequest('articles'), async (req, res) => {
try {
const {
title, excerpt, content, category, image_url,
author_id, is_published = false
} = req.body;
// Валидация обязательных полей
if (!title || !content) {
return res.status(400).json({
success: false,
error: 'Отсутствуют обязательные поля: title, content'
});
}
const result = await db.query(`
INSERT INTO articles (
title, excerpt, content, category, image_url,
author_id, is_published
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *
`, [
title, excerpt, content, category, image_url,
author_id, is_published
]);
res.status(201).json({ success: true, data: result.rows[0] });
} catch (error) {
handleError(res, error, 'создания статьи');
}
});
// PUT /api/crud/articles/:id - обновить статью
router.put('/articles/:id', logRequest('articles'), async (req, res) => {
try {
const id = validateId(req.params.id);
const {
title, excerpt, content, category, image_url,
author_id, is_published
} = req.body;
const result = await db.query(`
UPDATE articles SET
title = COALESCE($1, title),
excerpt = COALESCE($2, excerpt),
content = COALESCE($3, content),
category = COALESCE($4, category),
image_url = COALESCE($5, image_url),
author_id = COALESCE($6, author_id),
is_published = COALESCE($7, is_published),
updated_at = CURRENT_TIMESTAMP
WHERE id = $8
RETURNING *
`, [
title, excerpt, content, category, image_url,
author_id, is_published, id
]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Статья не найдена' });
}
res.json({ success: true, data: result.rows[0] });
} catch (error) {
handleError(res, error, 'обновления статьи');
}
});
// DELETE /api/crud/articles/:id - удалить статью
router.delete('/articles/:id', logRequest('articles'), async (req, res) => {
try {
const id = validateId(req.params.id);
const result = await db.query(`
DELETE FROM articles WHERE id = $1 RETURNING id, title
`, [id]);
if (result.rows.length === 0) {
return res.status(404).json({ success: false, error: 'Статья не найдена' });
}
res.json({
success: true,
message: `Статья "${result.rows[0].title}" успешно удалена`,
data: { id: result.rows[0].id }
});
} catch (error) {
handleError(res, error, 'удаления статьи');
}
});
/**
* ОБЩИЕ ЭНДПОИНТЫ
*/
// GET /api/crud/stats - общая статистика
router.get('/stats', async (req, res) => {
try {
const [routesCount, guidesCount, articlesCount] = 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_published = true')
]);
res.json({
success: true,
data: {
routes: parseInt(routesCount.rows[0].count),
guides: parseInt(guidesCount.rows[0].count),
articles: parseInt(articlesCount.rows[0].count),
timestamp: new Date().toISOString()
}
});
} catch (error) {
handleError(res, error, 'получения статистики');
}
});
export default router;

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

246
src/routes/image-upload.js Normal file
View File

@@ -0,0 +1,246 @@
import express from 'express';
import multer from 'multer';
import sharp from 'sharp';
import path from 'path';
import fs from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = express.Router();
// Настройка multer для загрузки файлов
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadPath = path.join(__dirname, '../../public/uploads/temp');
try {
await fs.mkdir(uploadPath, { recursive: true });
} catch (error) {
console.error('Error creating upload directory:', error);
}
cb(null, uploadPath);
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, `temp-${uniqueSuffix}${path.extname(file.originalname)}`);
}
});
const fileFilter = (req, file, cb) => {
// Проверяем, что это изображение
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error('Файл должен быть изображением'), false);
}
};
const upload = multer({
storage,
fileFilter,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB
}
});
/**
* Загрузка изображения во временную папку
*/
router.post('/upload-image', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Файл не загружен' });
}
// Получаем информацию об изображении
const metadata = await sharp(req.file.path).metadata();
res.json({
success: true,
tempId: path.basename(req.file.filename, path.extname(req.file.filename)),
filename: req.file.filename,
originalName: req.file.originalname,
size: req.file.size,
width: metadata.width,
height: metadata.height,
format: metadata.format,
tempUrl: `/uploads/temp/${req.file.filename}`
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({ error: 'Ошибка загрузки файла' });
}
});
/**
* Обработка изображения (поворот, отражение, обрезка)
*/
router.post('/process-image', async (req, res) => {
try {
const {
tempId,
rotation = 0,
flipHorizontal = false,
flipVertical = false,
cropData,
targetFolder = 'routes' // routes, guides, articles, etc.
} = req.body;
if (!tempId) {
return res.status(400).json({ error: 'Отсутствует ID временного файла' });
}
const tempPath = path.join(__dirname, '../../public/uploads/temp');
const targetPath = path.join(__dirname, `../../public/uploads/${targetFolder}`);
// Создаем целевую папку если её нет
await fs.mkdir(targetPath, { recursive: true });
// Ищем временный файл
const tempFiles = await fs.readdir(tempPath);
const tempFile = tempFiles.find(file => file.includes(tempId));
if (!tempFile) {
return res.status(404).json({ error: 'Временный файл не найден' });
}
const tempFilePath = path.join(tempPath, tempFile);
// Генерируем имя для финального файла
const timestamp = Date.now();
const finalFileName = `${targetFolder}-${timestamp}.jpg`;
const finalFilePath = path.join(targetPath, finalFileName);
// Обрабатываем изображение
let sharpInstance = sharp(tempFilePath);
// Поворот
if (rotation !== 0) {
sharpInstance = sharpInstance.rotate(rotation);
}
// Отражение
if (flipHorizontal || flipVertical) {
sharpInstance = sharpInstance.flip(flipVertical).flop(flipHorizontal);
}
// Обрезка
if (cropData && cropData.width > 0 && cropData.height > 0) {
sharpInstance = sharpInstance.extract({
left: Math.round(cropData.x),
top: Math.round(cropData.y),
width: Math.round(cropData.width),
height: Math.round(cropData.height)
});
}
// Сжимаем и сохраняем
await sharpInstance
.jpeg({ quality: 85 })
.toFile(finalFilePath);
// Удаляем временный файл
try {
await fs.unlink(tempFilePath);
} catch (error) {
console.warn('Не удалось удалить временный файл:', error.message);
}
res.json({
success: true,
url: `/uploads/${targetFolder}/${finalFileName}`,
filename: finalFileName
});
} catch (error) {
console.error('Image processing error:', error);
res.status(500).json({ error: 'Ошибка обработки изображения' });
}
});
/**
* Удаление временного файла
*/
router.delete('/temp-image/:tempId', async (req, res) => {
try {
const { tempId } = req.params;
const tempPath = path.join(__dirname, '../../public/uploads/temp');
const tempFiles = await fs.readdir(tempPath);
const tempFile = tempFiles.find(file => file.includes(tempId));
if (tempFile) {
await fs.unlink(path.join(tempPath, tempFile));
}
res.json({ success: true });
} catch (error) {
console.error('Delete temp image error:', error);
res.status(500).json({ error: 'Ошибка удаления временного файла' });
}
});
/**
* Получение списка изображений в папке
*/
router.get('/images/:folder', async (req, res) => {
try {
const { folder } = req.params;
const imagesPath = path.join(__dirname, `../../public/uploads/${folder}`);
try {
const files = await fs.readdir(imagesPath);
const images = files
.filter(file => /\.(jpg|jpeg|png|gif|webp)$/i.test(file))
.map(file => ({
filename: file,
url: `/uploads/${folder}/${file}`,
path: path.join(imagesPath, file)
}));
res.json({ success: true, images });
} catch (error) {
if (error.code === 'ENOENT') {
res.json({ success: true, images: [] });
} else {
throw error;
}
}
} catch (error) {
console.error('List images error:', error);
res.status(500).json({ error: 'Ошибка получения списка изображений' });
}
});
/**
* Удаление изображения
*/
router.delete('/image', async (req, res) => {
try {
const { url } = req.body;
if (!url || !url.startsWith('/uploads/')) {
return res.status(400).json({ error: 'Некорректный URL изображения' });
}
const imagePath = path.join(__dirname, '../../public', url);
try {
await fs.unlink(imagePath);
res.json({ success: true });
} catch (error) {
if (error.code === 'ENOENT') {
res.json({ success: true, message: 'Файл уже удален' });
} else {
throw error;
}
}
} catch (error) {
console.error('Delete image error:', error);
res.status(500).json({ error: 'Ошибка удаления изображения' });
}
});
export default router;

492
src/routes/images.js Normal file
View File

@@ -0,0 +1,492 @@
import express from 'express';
import multer from 'multer';
import sharp from 'sharp';
import path from 'path';
import fs from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = express.Router();
// Настройка multer для загрузки файлов
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
// Определяем папку на основе параметра или используем 'general'
const folder = req.body.folder || req.query.folder || 'general';
const uploadDir = path.join(__dirname, `../../public/uploads/${folder}`);
try {
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, `${uniqueSuffix}${ext}`);
}
});
const upload = multer({
storage,
limits: {
fileSize: 5 * 1024 * 1024, // 5MB
},
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Недопустимый тип файла'));
}
}
});
// POST /api/images/upload - загрузка изображения
router.post('/upload', upload.single('image'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({
success: false,
error: 'Файл не был загружен'
});
}
const { folder = 'general' } = req.body;
const originalPath = req.file.path;
const targetDir = path.join(__dirname, '../../public/uploads', folder);
// Создаем целевую папку если она не существует
await fs.mkdir(targetDir, { recursive: true });
// Генерируем имя файла
const ext = path.extname(req.file.originalname);
const filename = `${Date.now()}-${Math.round(Math.random() * 1E9)}${ext}`;
const targetPath = path.join(targetDir, filename);
// Оптимизируем изображение с помощью Sharp
await sharp(originalPath)
.resize(1200, 800, {
fit: 'inside',
withoutEnlargement: true
})
.jpeg({ quality: 85 })
.toFile(targetPath);
// Удаляем временный файл
await fs.unlink(originalPath);
const relativePath = `/uploads/${folder}/${filename}`;
res.json({
success: true,
data: {
path: relativePath,
originalName: req.file.originalname,
size: req.file.size,
folder
},
message: 'Изображение успешно загружено'
});
} catch (error) {
console.error('Upload error:', error);
res.status(500).json({
success: false,
error: error.message || 'Ошибка загрузки изображения'
});
}
});
// GET /api/images/gallery - получение списка изображений
router.get('/gallery', async (req, res) => {
try {
const { folder = 'all' } = req.query;
const uploadsDir = path.join(__dirname, '../../public/uploads');
let images = [];
if (folder === 'all') {
// Получаем изображения из всех папок
const folders = ['routes', 'guides', 'articles', 'general'];
for (const folderName of folders) {
const folderPath = path.join(uploadsDir, folderName);
try {
const files = await fs.readdir(folderPath);
const folderImages = files
.filter(file => /\.(jpg|jpeg|png|gif)$/i.test(file))
.map(file => ({
name: file,
path: `/uploads/${folderName}/${file}`,
folder: folderName,
fullPath: path.join(folderPath, file)
}));
images.push(...folderImages);
} catch (err) {
// Папка может не существовать - это нормально
}
}
} else {
// Получаем изображения из конкретной папки
const folderPath = path.join(uploadsDir, folder);
try {
const files = await fs.readdir(folderPath);
images = files
.filter(file => /\.(jpg|jpeg|png|gif)$/i.test(file))
.map(file => ({
name: file,
path: `/uploads/${folder}/${file}`,
folder,
fullPath: path.join(folderPath, file)
}));
} catch (err) {
// Папка может не существовать
}
}
// Получаем статистику файлов
const imagesWithStats = await Promise.all(
images.map(async (img) => {
try {
const stats = await fs.stat(img.fullPath);
return {
...img,
size: stats.size,
created: stats.birthtime,
modified: stats.mtime
};
} catch (err) {
return img;
}
})
);
// Сортируем по дате создания (новые сначала)
imagesWithStats.sort((a, b) => {
if (a.created && b.created) {
return new Date(b.created) - new Date(a.created);
}
return 0;
});
res.json({
success: true,
data: imagesWithStats,
total: imagesWithStats.length
});
} catch (error) {
console.error('Gallery error:', error);
res.status(500).json({
success: false,
error: error.message || 'Ошибка получения галереи'
});
}
});
// DELETE /api/images/:folder/:filename - удаление изображения
router.delete('/:folder/:filename', async (req, res) => {
try {
const { folder, filename } = req.params;
const filePath = path.join(__dirname, '../../public/uploads', folder, filename);
// Проверяем существование файла
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 || 'Ошибка удаления изображения'
});
}
});
// GET /api/images/info/:folder/:filename - получение информации об изображении
router.get('/info/:folder/:filename', async (req, res) => {
try {
const { folder, filename } = req.params;
const filePath = path.join(__dirname, '../../public/uploads', folder, filename);
const stats = await fs.stat(filePath);
const metadata = await sharp(filePath).metadata();
res.json({
success: true,
data: {
filename,
folder,
path: `/uploads/${folder}/${filename}`,
size: stats.size,
width: metadata.width,
height: metadata.height,
format: metadata.format,
created: stats.birthtime,
modified: stats.mtime
}
});
} catch (error) {
console.error('Info error:', error);
res.status(500).json({
success: false,
error: error.message || 'Ошибка получения информации об изображении'
});
}
});
// 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;

View File

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

176
src/routes/settings.js Normal file
View File

@@ -0,0 +1,176 @@
import express from 'express';
import SiteSettingsHelper from '../helpers/site-settings.js';
import fs from 'fs/promises';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = express.Router();
const STYLES_CONFIG_PATH = path.join(__dirname, '../../config/styles.json');
const CUSTOM_CSS_PATH = path.join(__dirname, '../../public/css/custom-styles.css');
/**
* Динамический CSS на основе настроек сайта
*/
router.get('/dynamic-styles.css', async (req, res) => {
try {
const css = await SiteSettingsHelper.generateCSSVariables();
res.setHeader('Content-Type', 'text/css');
res.setHeader('Cache-Control', 'public, max-age=300'); // Кеш на 5 минут
res.send(css);
} catch (error) {
console.error('Error generating dynamic CSS:', error);
res.status(500).send('/* Error generating dynamic CSS */');
}
});
// Загрузка настроек стилей для продвинутого редактора
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 для получения настроек сайта
*/
router.get('/api/site-settings', async (req, res) => {
try {
const settings = await SiteSettingsHelper.getAllSettings();
res.json(settings);
} catch (error) {
console.error('Error loading site settings:', error);
res.status(500).json({ error: 'Failed to load site settings' });
}
});
/**
* API для получения настроек по категории
*/
router.get('/api/site-settings/:category', async (req, res) => {
try {
const { category } = req.params;
const settings = await SiteSettingsHelper.getSettingsByCategory(category);
res.json(settings);
} catch (error) {
console.error('Error loading site settings by category:', error);
res.status(500).json({ error: 'Failed to load site settings' });
}
});
/**
* API для обновления настройки
*/
router.post('/api/site-settings', async (req, res) => {
try {
const { key, value, type, description, category } = req.body;
if (!key || value === undefined) {
return res.status(400).json({ error: 'Key and value are required' });
}
const success = await SiteSettingsHelper.setSetting(key, value, type, description, category);
if (success) {
res.json({ success: true, message: 'Setting updated successfully' });
} else {
res.status(500).json({ error: 'Failed to update setting' });
}
} catch (error) {
console.error('Error updating site setting:', error);
res.status(500).json({ error: 'Failed to update setting' });
}
});
/**
* Очистка кеша настроек (для админов)
*/
router.post('/api/site-settings/clear-cache', async (req, res) => {
try {
SiteSettingsHelper.clearCache();
res.json({ success: true, message: 'Cache cleared successfully' });
} catch (error) {
console.error('Error clearing cache:', error);
res.status(500).json({ error: 'Failed to clear cache' });
}
});
export default router;

14
src/routes/test.js Normal file
View 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;

370
test-crud.js Normal file
View File

@@ -0,0 +1,370 @@
#!/usr/bin/env node
/**
* Скрипт для тестирования всех CRUD операций
* Проверяет создание, чтение, обновление и удаление для Routes, Guides и Articles
*/
const BASE_URL = 'http://localhost:3000/api/crud';
// Цветной вывод в консоль
const colors = {
reset: '\x1b[0m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m'
};
const log = (color, message) => {
console.log(`${colors[color]}${message}${colors.reset}`);
};
// Утилита для HTTP запросов
async function request(method, url, data = null) {
const options = {
method,
headers: {
'Content-Type': 'application/json',
}
};
if (data) {
options.body = JSON.stringify(data);
}
const response = await fetch(url, options);
const result = await response.json();
return {
status: response.status,
data: result
};
}
// Тестовые данные
const testData = {
route: {
title: 'Тестовый маршрут CRUD',
description: 'Описание тестового маршрута для проверки CRUD операций',
content: 'Подробное описание маршрута с инструкциями',
type: 'city',
difficulty_level: 'easy',
price: 25000,
duration: 4,
max_group_size: 15,
image_url: '/uploads/routes/seoul-city-tour.jpg',
is_featured: false,
is_active: true
},
guide: {
name: 'Тестовый Гид CRUD',
email: 'test-guide-crud@example.com',
phone: '+82-10-1234-5678',
languages: 'Корейский, Английский, Русский',
specialization: 'city',
bio: 'Опытный гид для тестирования CRUD операций',
experience: 3,
image_url: '/uploads/guides/guide-profile.jpg',
hourly_rate: 30000,
is_active: true
},
article: {
title: 'Тестовая статья CRUD',
excerpt: 'Краткое описание тестовой статьи',
content: 'Полный текст тестовой статьи для проверки CRUD операций',
category: 'travel-tips',
image_url: '/images/articles/test-article.jpg',
author_id: 1,
is_published: true
}
};
// Основная функция тестирования
async function runCRUDTests() {
log('cyan', '🚀 Запуск тестирования CRUD операций...\n');
const results = {
routes: await testEntityCRUD('routes', testData.route),
guides: await testEntityCRUD('guides', testData.guide),
articles: await testEntityCRUD('articles', testData.article)
};
// Тестируем общие эндпоинты
log('blue', '📊 Тестирование общей статистики...');
try {
const statsResponse = await request('GET', `${BASE_URL}/stats`);
if (statsResponse.status === 200 && statsResponse.data.success) {
log('green', `✅ Статистика: ${JSON.stringify(statsResponse.data.data)}`);
} else {
log('red', `❌ Ошибка получения статистики: ${JSON.stringify(statsResponse.data)}`);
}
} catch (error) {
log('red', `❌ Ошибка получения статистики: ${error.message}`);
}
// Итоговый отчет
log('cyan', '\n📋 Итоговый отчет тестирования:');
Object.entries(results).forEach(([entity, result]) => {
const status = result.success ? '✅' : '❌';
log(result.success ? 'green' : 'red', `${status} ${entity.toUpperCase()}: ${result.message}`);
});
const totalTests = Object.values(results).length;
const passedTests = Object.values(results).filter(r => r.success).length;
log('cyan', `\n🎯 Результат: ${passedTests}/${totalTests} тестов прошли успешно`);
if (passedTests === totalTests) {
log('green', '🎉 Все CRUD операции работают корректно!');
} else {
log('red', '⚠️ Некоторые операции требуют внимания');
}
}
// Тестирование CRUD для конкретной сущности
async function testEntityCRUD(entity, testData) {
log('magenta', `\n🔍 Тестирование ${entity.toUpperCase()}...`);
let createdId = null;
const steps = [];
try {
// 1. CREATE - Создание записи
log('yellow', '1. CREATE - Создание записи...');
const createResponse = await request('POST', `${BASE_URL}/${entity}`, testData);
if (createResponse.status === 201 && createResponse.data.success) {
createdId = createResponse.data.data.id;
log('green', `✅ Создание успешно. ID: ${createdId}`);
steps.push('CREATE: ✅');
} else {
log('red', `❌ Ошибка создания: ${JSON.stringify(createResponse.data)}`);
steps.push('CREATE: ❌');
return { success: false, message: 'Ошибка создания записи', steps };
}
// 2. READ - Чтение записи по ID
log('yellow', '2. READ - Чтение записи по ID...');
const readResponse = await request('GET', `${BASE_URL}/${entity}/${createdId}`);
if (readResponse.status === 200 && readResponse.data.success) {
log('green', `✅ Чтение успешно. Заголовок: "${readResponse.data.data.title || readResponse.data.data.name}"`);
steps.push('READ: ✅');
} else {
log('red', `❌ Ошибка чтения: ${JSON.stringify(readResponse.data)}`);
steps.push('READ: ❌');
}
// 3. READ ALL - Чтение всех записей
log('yellow', '3. READ ALL - Чтение всех записей...');
const readAllResponse = await request('GET', `${BASE_URL}/${entity}?page=1&limit=5`);
if (readAllResponse.status === 200 && readAllResponse.data.success) {
const count = readAllResponse.data.data.length;
log('green', `✅ Получено записей: ${count}`);
steps.push('READ ALL: ✅');
} else {
log('red', `❌ Ошибка чтения списка: ${JSON.stringify(readAllResponse.data)}`);
steps.push('READ ALL: ❌');
}
// 4. UPDATE - Обновление записи
log('yellow', '4. UPDATE - Обновление записи...');
const updateData = entity === 'guides'
? { name: testData.name + ' (ОБНОВЛЕНО)' }
: { title: testData.title + ' (ОБНОВЛЕНО)' };
const updateResponse = await request('PUT', `${BASE_URL}/${entity}/${createdId}`, updateData);
if (updateResponse.status === 200 && updateResponse.data.success) {
log('green', '✅ Обновление успешно');
steps.push('UPDATE: ✅');
} else {
log('red', `❌ Ошибка обновления: ${JSON.stringify(updateResponse.data)}`);
steps.push('UPDATE: ❌');
}
// 5. DELETE - Удаление записи
log('yellow', '5. DELETE - Удаление записи...');
const deleteResponse = await request('DELETE', `${BASE_URL}/${entity}/${createdId}`);
if (deleteResponse.status === 200 && deleteResponse.data.success) {
log('green', `✅ Удаление успешно: ${deleteResponse.data.message}`);
steps.push('DELETE: ✅');
} else {
log('red', `❌ Ошибка удаления: ${JSON.stringify(deleteResponse.data)}`);
steps.push('DELETE: ❌');
}
// 6. Проверка удаления
log('yellow', '6. VERIFY DELETE - Проверка удаления...');
const verifyResponse = await request('GET', `${BASE_URL}/${entity}/${createdId}`);
if (verifyResponse.status === 404) {
log('green', '✅ Запись действительно удалена');
steps.push('VERIFY: ✅');
} else {
log('red', '❌ Запись не была удалена');
steps.push('VERIFY: ❌');
}
const successCount = steps.filter(s => s.includes('✅')).length;
const isSuccess = successCount === steps.length;
return {
success: isSuccess,
message: `${successCount}/6 операций успешно`,
steps
};
} catch (error) {
log('red', `❌ Критическая ошибка: ${error.message}`);
return {
success: false,
message: `Критическая ошибка: ${error.message}`,
steps: [...steps, 'ERROR: ❌']
};
}
}
// Дополнительные тесты для специфических функций
async function testAdvancedFeatures() {
log('cyan', '\n🔬 Тестирование расширенных функций...');
// Тест поиска
log('yellow', 'Тест поиска по routes...');
try {
const searchResponse = await request('GET', `${BASE_URL}/routes?search=seoul&limit=3`);
if (searchResponse.status === 200) {
log('green', `✅ Поиск работает. Найдено: ${searchResponse.data.data.length} записей`);
} else {
log('red', '❌ Ошибка поиска');
}
} catch (error) {
log('red', `❌ Ошибка поиска: ${error.message}`);
}
// Тест фильтрации
log('yellow', 'Тест фильтрации guides по специализации...');
try {
const filterResponse = await request('GET', `${BASE_URL}/guides?specialization=city&limit=3`);
if (filterResponse.status === 200) {
log('green', `✅ Фильтрация работает. Найдено: ${filterResponse.data.data.length} гидов`);
} else {
log('red', '❌ Ошибка фильтрации');
}
} catch (error) {
log('red', `❌ Ошибка фильтрации: ${error.message}`);
}
// Тест пагинации
log('yellow', 'Тест пагинации для articles...');
try {
const paginationResponse = await request('GET', `${BASE_URL}/articles?page=1&limit=2`);
if (paginationResponse.status === 200) {
const pagination = paginationResponse.data.pagination;
log('green', `✅ Пагинация работает. Страница ${pagination.page}, всего ${pagination.total} записей`);
} else {
log('red', '❌ Ошибка пагинации');
}
} catch (error) {
log('red', `❌ Ошибка пагинации: ${error.message}`);
}
}
// Тест валидации
async function testValidation() {
log('cyan', '\n🛡 Тестирование валидации...');
// Тест создания без обязательных полей
log('yellow', 'Тест создания маршрута без обязательных полей...');
try {
const invalidResponse = await request('POST', `${BASE_URL}/routes`, {
description: 'Только описание, без заголовка'
});
if (invalidResponse.status === 400) {
log('green', '✅ Валидация работает - отклонены невалидные данные');
} else {
log('red', '❌ Валидация не работает - приняты невалидные данные');
}
} catch (error) {
log('red', `❌ Ошибка тестирования валидации: ${error.message}`);
}
// Тест обновления несуществующей записи
log('yellow', 'Тест обновления несуществующей записи...');
try {
const notFoundResponse = await request('PUT', `${BASE_URL}/guides/99999`, {
name: 'Не существует'
});
if (notFoundResponse.status === 404) {
log('green', '✅ Корректно обрабатывается отсутствующая запись');
} else {
log('red', '❌ Неправильная обработка отсутствующей записи');
}
} catch (error) {
log('red', `❌ Ошибка тестирования несуществующей записи: ${error.message}`);
}
}
// Запуск всех тестов
async function runAllTests() {
try {
await runCRUDTests();
await testAdvancedFeatures();
await testValidation();
log('cyan', '\n🏁 Тестирование завершено!');
} catch (error) {
log('red', `💥 Критическая ошибка тестирования: ${error.message}`);
process.exit(1);
}
}
// Проверка доступности сервера
async function checkServer() {
try {
const response = await fetch('http://localhost:3000/health');
if (response.ok) {
log('green', '✅ Сервер доступен');
return true;
} else {
log('red', '❌ Сервер недоступен');
return false;
}
} catch (error) {
log('red', `❌ Сервер недоступен: ${error.message}`);
return false;
}
}
// Главная функция
async function main() {
log('cyan', '🧪 Система тестирования CRUD API');
log('cyan', '====================================\n');
// Проверяем доступность сервера
const serverAvailable = await checkServer();
if (!serverAvailable) {
log('red', 'Запустите сервер командой: docker-compose up -d');
process.exit(1);
}
// Запускаем тесты
await runAllTests();
}
// Запуск если файл выполняется напрямую
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch(error => {
log('red', `💥 Неожиданная ошибка: ${error.message}`);
process.exit(1);
});
}
export { runCRUDTests, testAdvancedFeatures, testValidation };

View File

@@ -6,6 +6,24 @@
</div>
</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 -->
<section class="py-5">
<div class="container">

View File

@@ -54,26 +54,23 @@
<div class="card border-0 shadow-lg" data-aos="fade-up">
<div class="card-body p-4">
<h4 class="text-center mb-4 fw-bold">Найдите своё идеальное корейское приключение</h4>
<form class="search-form">
<div class="row g-3">
<div class="col-md-4">
<select class="form-select form-select-lg" name="type">
<option value="">Тип тура</option>
<option value="city">Городские туры</option>
<option value="mountain">Горные походы</option>
<option value="fishing">Рыбалка</option>
</select>
</div>
<div class="col-md-4">
<input type="date" class="form-control form-control-lg" name="date">
</div>
<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>
</div>
<!-- Проверка доступности -->
<div id="availability-checker-container" class="mb-4"></div>
<!-- Выбор гида -->
<div id="guide-selector-container" class="mt-4" style="display: none;"></div>
<!-- Результаты поиска -->
<div id="searchResults" class="mt-4" style="display: none;">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0">Доступные туры</h5>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="clearSearchResults()">
<i class="fas fa-times me-1"></i>Очистить
</button>
</div>
</form>
<div id="searchResultsGrid" class="row g-3"></div>
</div>
</div>
</div>
</div>

View File

@@ -7,10 +7,13 @@
<meta name="description" content="<%= siteDescription %>">
<!-- Favicon -->
<link rel="icon" href="/images/favicon.ico">
<link rel="icon" href="<%= siteSettings.favicon_url || '/images/favicon.ico' %>">
<!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet">
<% if (siteSettings.google_fonts_url && siteSettings.google_fonts_url.trim()) { %>
<link href="<%= siteSettings.google_fonts_url %>" rel="stylesheet">
<% } %>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
@@ -26,6 +29,10 @@
<!-- Custom CSS -->
<link href="/css/main.css" rel="stylesheet">
<!-- Dynamic Site Settings CSS -->
<link href="/dynamic-styles.css" rel="stylesheet">
<!-- Custom User Styles CSS -->
<link href="/api/settings/styles/css" rel="stylesheet">
<!-- Open Graph Meta Tags -->
<meta property="og:title" content="<%= title || siteName %>">
@@ -42,7 +49,9 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
<div class="container">
<a class="navbar-brand" href="/">
<img src="/images/korea-logo.png" alt="Korea Tourism" height="40" class="me-2">
<% if (siteSettings.site_logo_url && siteSettings.site_logo_url.trim()) { %>
<img src="<%= siteSettings.site_logo_url %>" alt="<%= siteName %>" height="40" class="me-2">
<% } %>
<span class="fw-bold"><%= siteName %></span>
</a>
@@ -82,6 +91,11 @@
<i class="fas fa-user-tie me-1"></i>Гиды
</a>
</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">
<a class="nav-link <%= page === 'articles' ? 'active' : '' %>" href="/articles">
<i class="fas fa-newspaper me-1"></i>Статьи
@@ -188,6 +202,12 @@
<!-- Custom JS -->
<script src="/js/main.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 -->
<script>

262
views/routes/booking.ejs Normal file
View 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>

View File

@@ -41,7 +41,7 @@
<strong class="d-block">Максимум участников</strong>
<span><%= route.max_group_size %> человек</span>
</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>Забронировать
</a>
</div>