diff --git a/README.md b/README.md index c546805..68ea58b 100644 --- a/README.md +++ b/README.md @@ -1,115 +1,156 @@ # Shop Platform -Полнофункциональный интернет-магазин с RESTful API на Fastify и современным React фронтендом. +Полнофункциональный интернет-магазин с RESTful API на Fastify и современным React фронтендом с темной темой в стиле Telegram. ## ✨ Возможности +### Для клиентов - 🛍️ Каталог товаров с фильтрацией и поиском -- 🛒 Корзина покупок -- 📱 Адаптивный дизайн +- 🛒 Корзина покупок с сохранением состояния +- 📱 Полностью адаптивный дизайн (мобильная и десктопная версии) - 🔍 Поиск товаров -- 📂 Категории товаров -- 📄 Детальные страницы товаров -- 🎨 Современный и красивый UI +- 📂 Категории товаров с древовидной структурой +- 📄 Детальные страницы товаров с изображениями +- ⭐ Система отзывов и рейтингов +- 📍 Управление адресами доставки +- 📦 История заказов +- 🎨 Темная тема в стиле Telegram + +### Для администраторов +- 👥 Управление пользователями и ролями +- 🔐 Система прав доступа (RBAC) +- 📦 Управление заказами и статусами +- 🛍️ Управление товарами и категориями +- 📝 Модерация отзывов +- 📊 Админ-панель с интуитивным интерфейсом + +### Для сотрудников +- 📦 Просмотр и редактирование всех заказов +- 🔄 Изменение статусов заказов +- 🛒 Управление корзиной ## 🚀 Технологии ### Backend -- **Fastify** - быстрый веб-фреймворк для Node.js +- **Fastify 4** - быстрый веб-фреймворк для Node.js - **PostgreSQL** - реляционная база данных -- **Swagger/OpenAPI** - документация API +- **Swagger/OpenAPI** - автоматическая документация API с валидацией - **DAO Pattern** - паттерн доступа к данным -- **Keycloak** - интеграция с системой аутентификации (опционально) +- **JWT** - аутентификация через JSON Web Tokens +- **MinIO** - объектное хранилище для файлов и изображений - **YAML Config** - конфигурация через YAML файл +- **RBAC** - система ролей и прав доступа ### Frontend - **React 18** - современная UI библиотека - **Vite** - быстрый сборщик и dev-сервер - **React Router** - клиентский роутинг - **Axios** - HTTP клиент для API запросов +- **CSS Variables** - темная тема с переменными ## 📋 Требования - Node.js >= 18.0.0 - PostgreSQL >= 12.0 - npm или yarn +- MinIO (опционально, для хранения файлов) ## 🛠️ Установка -### Backend +### 1. Клонирование репозитория -1. Клонируйте репозиторий или используйте существующий проект +```bash +git clone https://git.mylocaldomain.online/abakumov-a-a/store-platform.git +cd shop-platform +``` -2. Установите зависимости: +### 2. Установка зависимостей + +**Backend:** ```bash npm install ``` -### Frontend - -1. Перейдите в папку клиента: +**Frontend:** ```bash cd client npm install +cd .. ``` -3. Настройте базу данных: - - Создайте базу данных PostgreSQL - - Выполните SQL скрипты в порядке: - ```bash - psql -U postgres -d shop_platform -f sql/schema.sql - psql -U postgres -d shop_platform -f sql/indexes.sql - psql -U postgres -d shop_platform -f sql/triggers.sql - psql -U postgres -d shop_platform -f sql/seed_data.sql - ``` +### 3. Настройка базы данных -4. Настройте конфигурацию: - - Отредактируйте `config.yaml` или установите переменные окружения - - Формат переменных: `${VAR_NAME:default_value}` +1. Создайте базу данных PostgreSQL: +```bash +createdb shop_platform +``` + +2. Выполните SQL скрипты в порядке: +```bash +psql -U postgres -d shop_platform -f sql/1_schema.sql +psql -U postgres -d shop_platform -f sql/2_seed_data.sql +psql -U postgres -d shop_platform -f sql/3_triggers.sql +psql -U postgres -d shop_platform -f sql/4_indexes.sql +psql -U postgres -d shop_platform -f sql/5_settings_init.sql +``` + +### 4. Настройка конфигурации + +Отредактируйте `conf/config.yml` или установите переменные окружения: + +```yaml +database: + host: ${POSTGRES_HOST:localhost} + port: ${POSTGRES_PORT:5432} + user: ${POSTGRES_USER:postgres} + password: ${POSTGRES_PASSWORD:password} + database: ${POSTGRES_DB:shop_platform} + +web: + host: ${WEB_HOST:0.0.0.0} + http_port: ${WEB_HTTP_PORT:80} + https_port: ${WEB_HTTPS_PORT:443} + tsl: ${WEB_TSL:false} + +minio: + endPoint: ${MINIO_ENDPOINT:localhost} + port: ${MINIO_PORT:9000} + useSSL: ${MINIO_USE_SSL:false} + accessKey: ${MINIO_ACCESS_KEY:minioadmin} + secretKey: ${MINIO_SECRET_KEY:minioadmin} +``` ## ⚙️ Конфигурация -Конфигурация загружается из файла `config.yaml` с поддержкой переменных окружения. +Конфигурация загружается из файла `conf/config.yml` с поддержкой переменных окружения. -### Формат конфигурации: +### Основные секции конфигурации: -```yaml -keycloak: - host: ${KEYCLOAK_HOST:158.160.87.50} - port: ${KEYCLOAK_PORT:10090} - realm: ${KEYCLOAK_REALM:sso} - # ... - -postgres: - host: ${POSTGRES_HOST:158.160.87.50} - port: ${POSTGRES_PORT:11432} - # ... - -web: - http_port: ${WEB_HTTP_PORT:80} - https_port: ${WEB_HTTPS_PORT:443} - # ... -``` +- **database** - настройки PostgreSQL +- **web** - настройки веб-сервера (порты, TLS) +- **minio** - настройки объектного хранилища (опционально) +- **keycloak** - настройки Keycloak (опционально) ### Переменные окружения: -Вы можете переопределить значения через переменные окружения: +Все значения можно переопределить через переменные окружения: ```bash export POSTGRES_HOST=localhost export POSTGRES_PORT=5432 -export KEYCLOAK_HOST=keycloak.example.com +export WEB_HTTP_PORT=3000 +export MINIO_ENDPOINT=localhost ``` ## 🏃 Запуск ### Backend -#### Режим разработки +**Режим разработки:** ```bash npm run dev ``` -#### Продакшн режим +**Продакшн режим:** ```bash npm start ``` @@ -118,7 +159,7 @@ npm start ### Frontend -#### Режим разработки +**Режим разработки:** ```bash cd client npm run dev @@ -126,7 +167,7 @@ npm run dev Фронтенд будет доступен по адресу `http://localhost:3000` -#### Сборка для продакшена +**Сборка для продакшена:** ```bash cd client npm run build @@ -140,141 +181,178 @@ npm run build - **Swagger UI**: http://localhost:80/docs (или указанный порт) - **Swagger JSON**: http://localhost:80/docs/json +Все эндпоинты имеют валидацию запросов и ответов через JSON Schema. + +## 🔐 Система ролей и прав доступа + +Проект использует систему ролей (RBAC) с гранулярными правами доступа: + +### Роли: +- **customer** - клиент (базовые права) +- **employee** - сотрудник (просмотр и редактирование заказов) +- **manager** - менеджер (расширенные права управления) +- **owner** - владелец (почти все права) +- **admin** - администратор (полные права) + +### Права доступа: +- `products.view`, `products.create`, `products.edit`, `products.delete` +- `orders.view`, `orders.create`, `orders.edit`, `orders.view_own` +- `categories.view`, `categories.create`, `categories.edit`, `categories.delete` +- `reviews.view`, `reviews.create`, `reviews.moderate`, `reviews.delete` +- `users.view`, `users.edit`, `users.delete` +- `cart.manage` +- `admin.all` - полные права администратора + +Управление правами доступно в админ-панели через интерфейс редактирования ролей. + +## 🔌 Основные API Endpoints + +### Аутентификация (Auth) +- `POST /api/v1/auth/register` - Регистрация +- `POST /api/v1/auth/login` - Вход +- `POST /api/v1/auth/telegram` - Вход через Telegram +- `GET /api/v1/auth/me` - Получить текущего пользователя +- `POST /api/v1/auth/change-password` - Изменить пароль + +### Товары (Products) +- `GET /api/v1/products` - Список товаров (с пагинацией и фильтрацией) +- `GET /api/v1/products/:id` - Детали товара +- `POST /api/v1/products` - Создать товар (требует прав) +- `PUT /api/v1/products/:id` - Обновить товар +- `DELETE /api/v1/products/:id` - Удалить товар + +### Категории (Categories) +- `GET /api/v1/categories` - Список категорий +- `GET /api/v1/categories/tree` - Дерево категорий +- `GET /api/v1/categories/:id` - Детали категории + +### Заказы (Orders) +- `GET /api/v1/orders` - Список заказов (с учетом роли) +- `GET /api/v1/orders/:id` - Детали заказа +- `POST /api/v1/orders` - Создать заказ +- `PUT /api/v1/orders/:id` - Обновить заказ +- `PUT /api/v1/orders/:id/status` - Изменить статус заказа + +### Корзина (Cart) +- `GET /api/v1/cart` - Получить корзину +- `POST /api/v1/cart/items` - Добавить товар в корзину +- `PUT /api/v1/cart/items/:id` - Обновить количество +- `DELETE /api/v1/cart/items/:id` - Удалить товар из корзины +- `DELETE /api/v1/cart` - Очистить корзину + +### Адреса (Addresses) +- `GET /api/v1/addresses` - Список адресов пользователя +- `POST /api/v1/addresses` - Добавить адрес +- `PUT /api/v1/addresses/:id` - Обновить адрес +- `DELETE /api/v1/addresses/:id` - Удалить адрес +- `PUT /api/v1/addresses/:id/set-default` - Установить адрес по умолчанию + +### Отзывы (Reviews) +- `GET /api/v1/reviews` - Список отзывов +- `GET /api/v1/reviews/product/:productId` - Отзывы товара +- `POST /api/v1/reviews` - Создать отзыв +- `PUT /api/v1/reviews/:id` - Обновить отзыв +- `DELETE /api/v1/reviews/:id` - Удалить отзыв + +### Админ-панель (Admin) +- `GET /api/v1/users` - Список пользователей +- `GET /api/v1/roles` - Список ролей +- `GET /api/v1/roles/:id/permissions` - Права роли +- `POST /api/v1/roles/:id/permissions` - Добавить право роли +- `DELETE /api/v1/roles/:id/permissions/:permissionId` - Удалить право роли +- `GET /api/v1/permissions` - Список всех прав доступа + ## 📁 Структура проекта ``` shop-platform/ -├── client/ # React фронтенд +├── client/ # React фронтенд │ ├── src/ -│ │ ├── api/ # API клиенты -│ │ ├── components/ # React компоненты -│ │ ├── pages/ # Страницы приложения -│ │ └── App.jsx # Главный компонент +│ │ ├── api/ # API клиенты +│ │ │ ├── admin/ # Админ API +│ │ │ └── client.js # Базовый клиент +│ │ ├── components/ # React компоненты +│ │ │ ├── admin/ # Админ компоненты +│ │ │ └── ... # Общие компоненты +│ │ ├── pages/ # Страницы приложения +│ │ │ ├── admin/ # Админ страницы +│ │ │ └── ... # Пользовательские страницы +│ │ ├── utils/ # Утилиты +│ │ └── App.jsx # Главный компонент │ ├── package.json │ └── vite.config.js ├── conf/ -│ └── config.yml # Конфигурационный файл +│ └── config.yml # Конфигурационный файл ├── src/ -│ ├── dao/ # Data Access Objects -│ ├── routes/ # Маршруты API -│ ├── plugins/ # Fastify плагины -│ └── utils/ # Утилиты -├── sql/ # SQL скрипты -├── tests/ # Тесты -├── app.js # Главный файл приложения -└── package.json +│ ├── dao/ # Data Access Objects +│ │ ├── BaseDAO.js # Базовый DAO +│ │ ├── UserDAO.js +│ │ ├── ProductDAO.js +│ │ ├── OrderDAO.js +│ │ ├── RoleDAO.js +│ │ ├── PermissionDAO.js +│ │ └── ... +│ ├── routes/ # API маршруты +│ │ └── v1/ # Версия API v1 +│ │ ├── auth.js +│ │ ├── products.js +│ │ ├── orders.js +│ │ ├── roles.js +│ │ ├── permissions.js +│ │ └── ... +│ ├── plugins/ # Fastify плагины +│ │ ├── authenticate.js # JWT аутентификация +│ │ ├── authorize.js # Проверка прав доступа +│ │ ├── dao.js # DAO плагин +│ │ └── minio.js # MinIO плагин +│ └── utils/ # Утилиты +├── sql/ # SQL скрипты +│ ├── 1_schema.sql # Схема БД +│ ├── 2_seed_data.sql # Начальные данные +│ ├── 3_triggers.sql # Триггеры +│ ├── 4_indexes.sql # Индексы +│ └── 5_settings_init.sql # Настройки +├── tests/ # Тесты +├── app.js # Главный файл приложения ├── package.json └── README.md ``` -## 🔌 API Endpoints +## 🎨 UI/UX Особенности -### Аутентификация (Auth) -- `POST /auth/register` - Регистрация по email и паролю -- `POST /auth/login` - Вход по email и паролю -- `POST /auth/telegram` - Вход/регистрация по Telegram ID -- `GET /auth/me` - Получить текущего пользователя (требует аутентификации) -- `POST /auth/change-password` - Изменить пароль (требует аутентификации) +- **Темная тема** в стиле Telegram с использованием CSS переменных +- **Адаптивный дизайн** для мобильных и десктопных устройств +- **Плавающие панели** для корзины, заказов и настроек +- **Модальные окна** для редактирования данных +- **Toggle switches** вместо стандартных чекбоксов +- **Интуитивная навигация** с вынесенными кнопками в нижний правый угол -### Товары (Products) -- `GET /products` - Получить список товаров -- `GET /products/:id` - Получить товар по ID -- `POST /products` - Создать товар -- `PUT /products/:id` - Обновить товар -- `DELETE /products/:id` - Удалить товар +## 🔒 Безопасность -### Категории (Categories) -- `GET /categories` - Получить список категорий -- `GET /categories/tree` - Получить дерево категорий -- `GET /categories/:id` - Получить категорию по ID +- JWT токены для аутентификации +- Хеширование паролей через bcrypt +- Проверка прав доступа на уровне API +- Валидация входных данных через JSON Schema +- CORS настройки для защиты от несанкционированных запросов +- Проверка владения ресурсами (пользователи могут редактировать только свои данные) -## 🔐 Аутентификация +## 🧪 Тестирование -Система поддерживает два способа аутентификации: - -### 1. По email и паролю ```bash -# Регистрация -curl -X POST http://localhost:80/auth/register \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "password": "password123", - "first_name": "Иван", - "last_name": "Иванов" - }' +# Запуск всех тестов +npm test -# Вход -curl -X POST http://localhost:80/auth/login \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "password": "password123" - }' +# Запуск тестов в watch режиме +npm run test:watch + +# Запуск только unit тестов +npm run test:unit + +# Запуск integration тестов +npm run test:integration ``` -### 2. По Telegram ID -```bash -curl -X POST http://localhost:80/auth/telegram \ - -H "Content-Type: application/json" \ - -d '{ - "telegram_id": 123456789, - "first_name": "Иван", - "last_name": "Иванов", - "phone": "+79001234567" - }' -``` - -### Использование токена -После успешной аутентификации вы получите JWT токен. Используйте его в заголовке: -```bash -curl http://localhost:80/auth/me \ - -H "Authorization: Bearer YOUR_JWT_TOKEN" -``` - -## 🔑 Keycloak интеграция - -Система поддерживает интеграцию с Keycloak для аутентификации. Настройки находятся в `config.yaml`: - -```yaml -keycloak: - host: ${KEYCLOAK_HOST:158.160.87.50} - port: ${KEYCLOAK_PORT:10090} - realm: ${KEYCLOAK_REALM:sso} - ssl: ${KEYCLOAK_TLS:false} - client_id: ${KEYCLOAK_CLIENT_ID:sso} - client_secret: ${KEYCLOAK_CLIENT_SECRET:...} -``` - -## 🔒 TLS/SSL - -Для включения HTTPS настройте в `config.yaml`: - -```yaml -web: - tsl: true - https_port: 443 - -tsl: - path: ./tsl - key: key.pem - cert: cert.pem -``` - -Сертификаты должны находиться в указанной директории. - -## 📝 Переменные окружения - -Все настройки можно переопределить через переменные окружения. Формат в `config.yaml`: `${VAR_NAME:default_value}` - -Основные переменные: -- `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` -- `KEYCLOAK_HOST`, `KEYCLOAK_PORT`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET` -- `WEB_HTTP_PORT`, `WEB_HTTPS_PORT`, `WEB_HOST`, `WEB_TSL` -- `JWT_SECRET`, `JWT_EXPIRES_IN` -- `SYNC_TOKEN` - -## 🧪 Разработка +## 📝 Разработка ### Добавление нового DAO @@ -283,30 +361,61 @@ tsl: import { BaseDAO } from './BaseDAO.js'; export class NewDAO extends BaseDAO { - constructor() { - super('table_name'); + constructor(pool) { + super(pool, 'table_name'); } // Кастомные методы + async findCustom() { + const result = await this.pool.query('SELECT * FROM table_name'); + return result.rows; + } } ``` +2. Зарегистрируйте в `src/plugins/dao.js` + ### Добавление новых роутов -1. Создайте файл в `src/routes/`: +1. Создайте файл в `src/routes/v1/`: ```javascript -export async function newRoutes(fastify, options) { - fastify.get('/new', async (request, reply) => { - return { success: true }; +export default async function newRoutes(fastify, options) { + const { NewDAO } = fastify.dao; + + fastify.get('/new', { + schema: { + tags: ['new'], + summary: 'Получить список', + security: [{ bearerAuth: [] }] + }, + preHandler: [ + fastify.authenticate, + fastify.checkPermission(['new.view']) + ] + }, async (request, reply) => { + const data = await NewDAO.findAll(); + return { success: true, data }; }); } ``` -2. Зарегистрируйте в `src/server.js`: -```javascript -import { newRoutes } from './routes/new.routes.js'; -await fastify.register(newRoutes); -``` +2. Файл автоматически загрузится через `@fastify/autoload` + +## 🔧 Переменные окружения + +Основные переменные окружения: + +**База данных:** +- `POSTGRES_HOST`, `POSTGRES_PORT`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` + +**Веб-сервер:** +- `WEB_HTTP_PORT`, `WEB_HTTPS_PORT`, `WEB_HOST`, `WEB_TSL` + +**MinIO:** +- `MINIO_ENDPOINT`, `MINIO_PORT`, `MINIO_USE_SSL`, `MINIO_ACCESS_KEY`, `MINIO_SECRET_KEY` + +**Keycloak (опционально):** +- `KEYCLOAK_HOST`, `KEYCLOAK_PORT`, `KEYCLOAK_REALM`, `KEYCLOAK_CLIENT_ID`, `KEYCLOAK_CLIENT_SECRET` ## 📄 Лицензия @@ -315,3 +424,7 @@ ISC ## 👥 Авторы Проект создан для интернет-магазина. + +## 🔗 Репозиторий + +Git репозиторий: https://git.mylocaldomain.online/abakumov-a-a/store-platform.git diff --git a/client/src/App.jsx b/client/src/App.jsx index 653f217..193f3f8 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -9,6 +9,7 @@ import ProductDetail from './pages/ProductDetail' import Cart from './pages/Cart' import Categories from './pages/Categories' import Orders from './pages/Orders' +import MyOrders from './pages/MyOrders' import Profile from './pages/Profile' import Login from './pages/Login' import AdminLayout from './components/admin/AdminLayout' @@ -95,6 +96,16 @@ function App() { } /> + + <> +
+
+ +
+ + + } /> <> diff --git a/client/src/api/auth.js b/client/src/api/auth.js index 1ef6009..6fed87c 100644 --- a/client/src/api/auth.js +++ b/client/src/api/auth.js @@ -56,10 +56,15 @@ export const authApi = { // Сменить пароль changePassword: async (oldPassword, newPassword) => { - const response = await apiClient.post('/auth/change-password', { - old_password: oldPassword, + const payload = { new_password: newPassword - }) + } + // old_password не обязателен (для пользователей без установленного пароля) + if (oldPassword && oldPassword.trim() !== '') { + payload.old_password = oldPassword + } + + const response = await apiClient.post('/auth/change-password', payload) return response.data }, diff --git a/client/src/api/orders.js b/client/src/api/orders.js index 6ffccf9..e0d622b 100644 --- a/client/src/api/orders.js +++ b/client/src/api/orders.js @@ -30,7 +30,7 @@ export const ordersApi = { return response.data }, - // Получить заказы текущего пользователя + // Получить заказы (в зависимости от прав: свои или все) getMyOrders: async (params = {}) => { if (!isAuthenticated()) { const error = new Error('Пользователь не авторизован') @@ -38,19 +38,28 @@ export const ordersApi = { throw error } - const user = getUser() - const userId = user?.id || localStorage.getItem('userId') - - if (!userId) { - return { success: true, data: [], pagination: null } - } - + // Роут сам определит права доступа и вернет нужные заказы + // Для клиентов - только свои, для сотрудников/админов - все const queryParams = new URLSearchParams() if (params.page) queryParams.append('page', params.page) if (params.limit) queryParams.append('limit', params.limit) - queryParams.append('user_id', userId) - const response = await apiClient.get(`/orders?${queryParams.toString()}`) + const queryString = queryParams.toString() + const url = queryString ? `/orders?${queryString}` : '/orders' + + console.log('[ordersApi.getMyOrders] Запрос:', url, 'params:', params) + const response = await apiClient.get(url) + console.log('[ordersApi.getMyOrders] Ответ получен:', { + status: response.status, + hasData: !!response.data, + dataType: typeof response.data, + dataKeys: response.data ? Object.keys(response.data) : [], + dataSuccess: response.data?.success, + dataDataIsArray: Array.isArray(response.data?.data), + dataDataLength: response.data?.data?.length + }) + console.log('[ordersApi.getMyOrders] Полный ответ:', JSON.stringify(response.data, null, 2)) + return response.data }, diff --git a/client/src/components/FloatingOrdersPanel.css b/client/src/components/FloatingOrdersPanel.css index 22d0f9f..c7467aa 100644 --- a/client/src/components/FloatingOrdersPanel.css +++ b/client/src/components/FloatingOrdersPanel.css @@ -214,6 +214,51 @@ flex-shrink: 0; } +/* Карточки необработанных заказов */ +.floating-orders-card { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1rem; + margin-bottom: 0.75rem; + cursor: pointer; + transition: all 0.2s; + backdrop-filter: blur(10px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.floating-orders-card:hover { + background: rgba(255, 255, 255, 0.18); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.floating-orders-card-number { + font-size: 1.125rem; + font-weight: 600; + color: #ffffff; + margin-bottom: 0.5rem; + word-wrap: break-word; +} + +.floating-orders-card-date { + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.8); +} + +.floating-orders-card-status { + margin-top: 0.5rem; + font-size: 0.875rem; + font-weight: 500; +} + +.floating-orders-card-total { + margin-top: 0.5rem; + font-size: 1rem; + font-weight: 600; + color: #ffffff; +} + .floating-orders-view-all { width: 100%; margin-top: 1rem; @@ -281,3 +326,236 @@ max-height: 80vh; } } + +/* Стили для модального окна с деталями заказа */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease; +} + +.modal-content.order-details { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 16px; + padding: 2rem; + max-width: 90vw; + width: 600px; + max-height: 90vh; + overflow-y: auto; + color: var(--text-primary); + box-shadow: var(--shadow-lg); + z-index: 2001; +} + +.modal-content.order-details h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: #ffffff; +} + +.modal-content.order-details .order-info { + margin-bottom: 1.5rem; +} + +.modal-content.order-details .info-row { + padding: 0.75rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.modal-content.order-details .info-row strong { + color: #ffffff; + margin-right: 0.5rem; +} + +.modal-content.order-details .order-items { + margin-top: 2rem; +} + +.modal-content.order-details .order-items h3 { + margin-bottom: 1rem; + color: #ffffff; +} + +.modal-content.order-details .order-items-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal-content.order-details .order-item-card { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1rem; + color: #ffffff; +} + +.modal-content.order-details .order-item-name { + font-weight: 600; + margin-bottom: 0.5rem; + color: #ffffff; +} + +.modal-content.order-details .order-item-row { + display: flex; + justify-content: space-between; + margin-bottom: 0.25rem; + color: #ffffff; +} + +.modal-content.order-details .form-actions { + display: flex; + justify-content: flex-end; + margin-top: 1.5rem; + gap: 1rem; +} + +.modal-content.order-details .btn { + padding: 0.75rem 1.5rem; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s; +} + +.modal-content.order-details .btn-secondary { + background-color: #95a5a6; + color: white; +} + +.modal-content.order-details .btn-secondary:hover { + background-color: #7f8c8d; +} + +@media (max-width: 768px) { + .modal-content.order-details { + max-width: 95vw; + padding: 1.5rem; + max-height: 85vh; + } +} + +/* Кнопки действий с иконками */ +.order-actions-row { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + flex-wrap: wrap; +} + +.order-action-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + font-size: 0.9rem; + color: #ffffff; +} + +.order-action-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.order-action-btn:active:not(:disabled) { + transform: translateY(0); +} + +.order-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.order-action-btn svg { + flex-shrink: 0; +} + +/* Модальное окно выбора статуса */ +.status-select-modal { + max-width: 500px; +} + +.status-select-modal h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: #ffffff; + font-size: 1.5rem; + font-weight: 600; +} + +.status-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.status-option { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border: 2px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + background: rgba(255, 255, 255, 0.1); + cursor: pointer; + transition: all 0.2s; + text-align: left; + font-size: 1rem; + color: #ffffff; + width: 100%; +} + +.status-option:hover:not(:disabled) { + border-color: rgba(255, 255, 255, 0.3); + transform: translateX(4px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + background: rgba(255, 255, 255, 0.15); +} + +.status-option.active { + font-weight: 600; +} + +.status-option:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.status-color-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-name { + font-weight: 500; +} + +.status-description { + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.7); + margin-top: 0.25rem; +} diff --git a/client/src/components/FloatingOrdersPanel.jsx b/client/src/components/FloatingOrdersPanel.jsx index 6278ce5..f77c70e 100644 --- a/client/src/components/FloatingOrdersPanel.jsx +++ b/client/src/components/FloatingOrdersPanel.jsx @@ -10,8 +10,14 @@ function FloatingOrdersPanel() { const [isOpen, setIsOpen] = useState(false) const [orders, setOrders] = useState([]) const [loading, setLoading] = useState(false) + const [selectedOrder, setSelectedOrder] = useState(null) + const [orderStatuses, setOrderStatuses] = useState([]) + const [paymentStatuses, setPaymentStatuses] = useState([]) + const [updatingStatus, setUpdatingStatus] = useState(false) + const [showStatusModal, setShowStatusModal] = useState(false) const navigate = useNavigate() const userHasOrderAccess = hasOrderAccess() + const canEditOrders = hasOrderEditAccess() useEffect(() => { const loadOrders = async () => { @@ -24,19 +30,146 @@ function FloatingOrdersPanel() { try { if (userHasOrderAccess) { // Для пользователей с доступом к заказам загружаем необработанные заказы - const count = await adminOrdersApi.getPendingCount() - setPendingCount(count || 0) + setLoading(true) + + // Получаем статусы + const statusesResponse = await adminOrdersApi.getStatuses() + if (statusesResponse.success) { + const statuses = statusesResponse.data || [] + const pendingStatus = statuses.find(s => s.slug === 'pending') + const processingStatus = statuses.find(s => s.slug === 'processing') + + let allPendingOrders = [] + + // Загружаем заказы со статусом "pending" + if (pendingStatus) { + try { + const response = await adminOrdersApi.getAll({ status_id: pendingStatus.id, limit: 50 }) + if (response.success && response.data) { + allPendingOrders = [...allPendingOrders, ...response.data] + } + } catch (error) { + console.error('Ошибка загрузки pending заказов:', error) + } + } + + // Загружаем заказы со статусом "processing" + if (processingStatus) { + try { + const response = await adminOrdersApi.getAll({ status_id: processingStatus.id, limit: 50 }) + if (response.success && response.data) { + allPendingOrders = [...allPendingOrders, ...response.data] + } + } catch (error) { + console.error('Ошибка загрузки processing заказов:', error) + } + } + + // Сортируем по дате создания (новые первыми) + allPendingOrders.sort((a, b) => { + const dateA = new Date(a.created_at || 0) + const dateB = new Date(b.created_at || 0) + return dateB - dateA + }) + + setOrders(allPendingOrders) + setPendingCount(allPendingOrders.length) + } else { + const count = await adminOrdersApi.getPendingCount() + setPendingCount(count || 0) + setOrders([]) + } + setLoading(false) } else { - // Для обычного пользователя загружаем последние заказы - const response = await ordersApi.getMyOrders({ page: 1, limit: 5 }) - if (response && response.success && response.data) { - setOrders(response.data.orders || []) + // Для обычного пользователя загружаем все заказы и фильтруем только в обработке + console.log('[FloatingOrdersPanel] Загружаем заказы для обычного пользователя') + setLoading(true) + + try { + // Получаем статусы заказов (пробуем через admin API, если не получится - используем slug из заказов) + let orderStatuses = [] + try { + const statusesResponse = await adminOrdersApi.getStatuses() + if (statusesResponse.success && statusesResponse.data) { + orderStatuses = statusesResponse.data + } + } catch (statusError) { + console.warn('[FloatingOrdersPanel] Не удалось загрузить статусы через admin API, используем slug из заказов') + } + + // Загружаем все заказы пользователя (большой лимит, чтобы получить все) + const response = await ordersApi.getMyOrders({ page: 1, limit: 100 }) + console.log('[FloatingOrdersPanel] Ответ API:', response) + + if (response && response.success && response.data) { + const allOrders = Array.isArray(response.data) ? response.data : [] + console.log('[FloatingOrdersPanel] Всего заказов:', allOrders.length) + + // Находим ID статусов "pending" и "processing" + let pendingStatusId = null + let processingStatusId = null + + if (orderStatuses.length > 0) { + const pendingStatus = orderStatuses.find(s => s.slug === 'pending') + const processingStatus = orderStatuses.find(s => s.slug === 'processing') + pendingStatusId = pendingStatus?.id + processingStatusId = processingStatus?.id + console.log('[FloatingOrdersPanel] pendingStatusId:', pendingStatusId, 'processingStatusId:', processingStatusId) + } + + // Фильтруем заказы в обработке (pending или processing) + const processingOrders = allOrders.filter(order => { + // Проверяем по status_id, если есть статусы + if (pendingStatusId !== null || processingStatusId !== null) { + return order.status_id === pendingStatusId || order.status_id === processingStatusId + } + // Если нет статусов, проверяем по status_slug (если есть в данных) + if (order.status_slug) { + return order.status_slug === 'pending' || order.status_slug === 'processing' + } + // Если нет slug, проверяем по status_name (на русском) + if (order.status_name) { + const statusName = order.status_name.toLowerCase() + return statusName.includes('ожидает') || statusName.includes('обработк') + } + // Если ничего не найдено, не показываем заказ + return false + }) + + console.log('[FloatingOrdersPanel] Заказов в обработке:', processingOrders.length) + + // Сортируем по дате создания (новые первыми) + processingOrders.sort((a, b) => { + const dateA = new Date(a.created_at || 0) + const dateB = new Date(b.created_at || 0) + return dateB - dateA + }) + + // Берем только последние 5 для отображения + const displayOrders = processingOrders.slice(0, 5) + + setOrders(displayOrders) + // Устанавливаем счетчик равным количеству заказов в обработке + setPendingCount(processingOrders.length) + console.log('[FloatingOrdersPanel] Установлено заказов для отображения:', displayOrders.length, 'всего в обработке:', processingOrders.length) + } else { + console.warn('[FloatingOrdersPanel] Не удалось загрузить заказы:', response) + setOrders([]) + setPendingCount(0) + } + } catch (error) { + console.error('[FloatingOrdersPanel] Ошибка загрузки заказов для обычного пользователя:', error) + setOrders([]) setPendingCount(0) + } finally { + setLoading(false) } } } catch (error) { + console.error('Ошибка загрузки заказов:', error) setPendingCount(0) setOrders([]) + setLoading(false) } } @@ -50,15 +183,6 @@ function FloatingOrdersPanel() { } }, [userHasOrderAccess]) - const formatDate = (dateString) => { - if (!dateString) return '—' - const date = new Date(dateString) - return date.toLocaleDateString('ru-RU', { - day: '2-digit', - month: '2-digit', - year: 'numeric' - }) - } const formatPrice = (price) => { return new Intl.NumberFormat('ru-RU', { @@ -69,10 +193,155 @@ function FloatingOrdersPanel() { }).format(price) } - const handleViewOrder = (orderId) => { - setIsOpen(false) - navigate('/orders') - // Можно добавить логику для открытия конкретного заказа + const loadStatuses = async () => { + try { + const [statusesResponse, paymentStatusesResponse] = await Promise.all([ + adminOrdersApi.getStatuses(), + adminOrdersApi.getPaymentStatuses() + ]) + + if (statusesResponse.success) { + setOrderStatuses(statusesResponse.data || []) + } + if (paymentStatusesResponse.success) { + setPaymentStatuses(paymentStatusesResponse.data || []) + } + } catch (error) { + console.error('Ошибка загрузки статусов:', error) + } + } + + const handleViewOrder = async (orderId) => { + console.log('[FloatingOrdersPanel] handleViewOrder вызван для orderId:', orderId) + console.log('[FloatingOrdersPanel] userHasOrderAccess:', userHasOrderAccess, 'canEditOrders:', canEditOrders) + + try { + let response + + // Для пользователей с доступом к заказам используем admin API + if (userHasOrderAccess) { + console.log('[FloatingOrdersPanel] Используем adminOrdersApi.getById') + try { + response = await adminOrdersApi.getById(orderId) + if (response.success) { + setSelectedOrder(response.data) + setIsOpen(false) + // Загружаем статусы при открытии модального окна + if (canEditOrders) { + loadStatuses() + } + return + } + } catch (adminError) { + console.error('[FloatingOrdersPanel] Ошибка загрузки заказа через admin API:', adminError) + // Если ошибка 403/401 - пробуем обычный API + if (adminError.response?.status === 403 || adminError.response?.status === 401) { + console.log('[FloatingOrdersPanel] Нет прав админа, используем обычный API') + } else { + throw adminError + } + } + } + + // Для обычных пользователей или если admin API не сработал - используем обычный API + console.log('[FloatingOrdersPanel] Используем ordersApi.getById для orderId:', orderId) + response = await ordersApi.getById(orderId) + console.log('[FloatingOrdersPanel] Ответ от ordersApi.getById:', response) + if (response && response.success) { + console.log('[FloatingOrdersPanel] Заказ загружен через ordersApi, устанавливаем selectedOrder') + console.log('[FloatingOrdersPanel] Данные заказа:', response.data) + setSelectedOrder(response.data) + setIsOpen(false) + console.log('[FloatingOrdersPanel] selectedOrder установлен, модальное окно должно открыться') + } else { + console.error('[FloatingOrdersPanel] API вернул success: false или нет success') + console.error('[FloatingOrdersPanel] Ответ:', response) + alert('Не удалось загрузить заказ: ' + (response?.message || 'Неизвестная ошибка')) + } + } catch (error) { + console.error('[FloatingOrdersPanel] Ошибка загрузки заказа:', error) + alert('Ошибка загрузки заказа: ' + (error.response?.data?.message || error.message)) + } + } + + const handleUpdateStatus = async (orderId, statusId) => { + if (!canEditOrders) return + + try { + setUpdatingStatus(true) + const response = await adminOrdersApi.updateStatus(orderId, statusId) + if (response.success) { + // Обновляем заказ + const updatedOrder = await adminOrdersApi.getById(orderId) + if (updatedOrder.success) { + setSelectedOrder(updatedOrder.data) + } + alert('Статус заказа успешно обновлен') + } + } catch (error) { + alert('Ошибка обновления статуса: ' + (error.response?.data?.message || error.message)) + } finally { + setUpdatingStatus(false) + } + } + + const handleUpdatePaymentStatus = async (orderId, paymentStatusId) => { + if (!canEditOrders) return + + try { + setUpdatingStatus(true) + const response = await adminOrdersApi.updatePaymentStatus(orderId, paymentStatusId) + if (response.success) { + // Обновляем заказ + const updatedOrder = await adminOrdersApi.getById(orderId) + if (updatedOrder.success) { + setSelectedOrder(updatedOrder.data) + } + alert('Статус оплаты успешно обновлен') + } + } catch (error) { + alert('Ошибка обновления статуса оплаты: ' + (error.response?.data?.message || error.message)) + } finally { + setUpdatingStatus(false) + } + } + + const handleConfirmPayment = async () => { + if (!canEditOrders || !selectedOrder) return + + // Находим статус "paid" (оплачен) + const paidStatus = paymentStatuses.find(s => s.slug === 'paid') + if (!paidStatus) { + alert('Статус "Оплачен" не найден') + return + } + + await handleUpdatePaymentStatus(selectedOrder.id, paidStatus.id) + } + + const formatDate = (dateString) => { + if (!dateString) return '—' + return new Date(dateString).toLocaleString('ru-RU') + } + + const formatDateOnly = (dateString) => { + if (!dateString) return '—' + const date = new Date(dateString) + return date.toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }) + } + + const formatTimeOnly = (dateString) => { + if (!dateString) return '—' + const date = new Date(dateString) + return date.toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) } if (!isAuthenticated()) { @@ -123,14 +392,56 @@ function FloatingOrdersPanel() {
{userHasOrderAccess ? (
- {pendingCount > 0 ? ( -
-

Необработанных заказов: {pendingCount}

+ {loading ? ( +
+

Загрузка...

+
+ ) : orders.length > 0 ? ( +
+ {orders.map((order) => ( +
{ + e.stopPropagation() + console.log('[FloatingOrdersPanel] Клик на карточку заказа (админ):', order.id) + handleViewOrder(order.id) + }} + > +
+ {order.order_number || `#${order.id}`} +
+
+ {formatDate(order.created_at)} +
+ {order.status_name && ( +
+ {order.status_name} +
+ )} + {order.total_amount && ( +
+ {formatPrice(order.total_amount)} +
+ )} +
+ ))}
)} + + {/* Модальное окно с деталями заказа */} + {selectedOrder && ( +
setSelectedOrder(null)}> +
e.stopPropagation()}> +

+ {selectedOrder.order_number || `#${selectedOrder.id}`} +

+
+
+ Дата: {formatDateOnly(selectedOrder.created_at)} {formatTimeOnly(selectedOrder.created_at)} +
+
+ Сумма: {formatPrice(selectedOrder.total_amount || 0)} +
+ {selectedOrder.status_name && ( +
+ Статус: +
+ {selectedOrder.status_name} +
+
+ )} + {selectedOrder.payment_status_name && ( +
+ Оплата: +
+ {selectedOrder.payment_status_name} +
+
+ )} + {selectedOrder.user && ( +
+ Пользователь: { + selectedOrder.user + ? `${selectedOrder.user.first_name || ''} ${selectedOrder.user.last_name || ''}`.trim() || selectedOrder.user.email || `ID: ${selectedOrder.user_id}` + : selectedOrder.user_id || '—' + } +
+ )} + {selectedOrder.user?.email && ( +
+ Почта: {selectedOrder.user.email} +
+ )} + {selectedOrder.user?.phone && ( +
+ Телефон: {selectedOrder.user.phone} +
+ )} + {selectedOrder.shipping_address && ( +
+ Адрес доставки: +
+
{selectedOrder.shipping_address.first_name} {selectedOrder.shipping_address.last_name}
+ {selectedOrder.shipping_address.phone && ( +
Телефон: {selectedOrder.shipping_address.phone}
+ )} +
+ {selectedOrder.shipping_address.country}, {selectedOrder.shipping_address.city} +
+
+ {selectedOrder.shipping_address.street} + {selectedOrder.shipping_address.building && `, д. ${selectedOrder.shipping_address.building}`} + {selectedOrder.shipping_address.apartment && `, кв. ${selectedOrder.shipping_address.apartment}`} +
+ {selectedOrder.shipping_address.postal_code && ( +
Индекс: {selectedOrder.shipping_address.postal_code}
+ )} +
+
+ )} + {selectedOrder.items && selectedOrder.items.length > 0 && ( +
+

Товары:

+
+ {selectedOrder.items.map((item, idx) => ( +
+
+ {item.product_name || 'Товар'} + {item.product_sku && ( +
+ Артикул: {item.product_sku} +
+ )} +
+
+ Количество: + {item.quantity} +
+
+ Цена: + {formatPrice(item.price || 0)} +
+
+ Сумма: + {formatPrice(item.subtotal || (item.price || 0) * item.quantity)} +
+
+ ))} +
+
+ )} +
+ + {canEditOrders && ( +
+ + + +
+ )} + +
+ +
+
+
+ )} + + {/* Модальное окно выбора статуса заказа */} + {showStatusModal && selectedOrder && (() => { + // Проверяем, оплачен ли заказ + const paidStatus = paymentStatuses.find(s => s.slug === 'paid' || s.slug === 'completed') + const isPaid = paidStatus && selectedOrder.payment_status_id === paidStatus.id + + // Статусы, которые требуют оплаты + const requiresPaymentStatuses = ['shipped', 'delivered'] + + return ( +
setShowStatusModal(false)}> +
e.stopPropagation()}> +

Выберите статус заказа

+
+ {orderStatuses.map(status => { + const requiresPayment = requiresPaymentStatuses.includes(status.slug) + const isDisabled = updatingStatus || + status.id === selectedOrder.status_id || + (requiresPayment && !isPaid) + + return ( + + ) + })} +
+
+ +
+
+
+ ) + })()} ) } diff --git a/client/src/components/OrderDetailsModal.jsx b/client/src/components/OrderDetailsModal.jsx new file mode 100644 index 0000000..f059374 --- /dev/null +++ b/client/src/components/OrderDetailsModal.jsx @@ -0,0 +1,560 @@ +import { useState, useEffect } from 'react' +import { adminOrdersApi } from '../api/admin/orders' +import { addressesApi } from '../api/addresses' + +function OrderDetailsModal({ order: orderProp, onClose, canEditOrders, onOrderUpdate }) { + const [order, setOrder] = useState(orderProp) + const [editingAddress, setEditingAddress] = useState(null) + const [updatingStatus, setUpdatingStatus] = useState(false) + const [showStatusModal, setShowStatusModal] = useState(false) + const [orderStatuses, setOrderStatuses] = useState([]) + const [paymentStatuses, setPaymentStatuses] = useState([]) + const [addressForm, setAddressForm] = useState({ + first_name: '', + last_name: '', + phone: '', + country: 'Россия', + city: '', + street: '', + building: '', + apartment: '', + postal_code: '' + }) + + // Обновляем локальное состояние при изменении пропа + useEffect(() => { + if (orderProp) { + setOrder(orderProp) + } else { + // Если orderProp стал null, сбрасываем локальное состояние + setOrder(null) + } + }, [orderProp]) + + // Логируем canEditOrders для отладки + useEffect(() => { + console.log('[OrderDetailsModal] canEditOrders:', canEditOrders, 'order:', order?.id, 'orderProp:', orderProp?.id) + }, [canEditOrders, order, orderProp]) + + useEffect(() => { + const currentOrder = order || orderProp + if (canEditOrders && currentOrder) { + console.log('[OrderDetailsModal] Загружаем статусы, canEditOrders:', canEditOrders) + loadStatuses() + } + }, [canEditOrders, order, orderProp]) + + const loadStatuses = async () => { + try { + const [statusesResponse, paymentStatusesResponse] = await Promise.all([ + adminOrdersApi.getStatuses(), + adminOrdersApi.getPaymentStatuses() + ]) + + if (statusesResponse.success) { + setOrderStatuses(statusesResponse.data || []) + } + if (paymentStatusesResponse.success) { + setPaymentStatuses(paymentStatusesResponse.data || []) + } + } catch (error) { + console.error('Ошибка загрузки статусов:', error) + } + } + + const handleUpdateStatus = async (orderId, statusId) => { + if (!canEditOrders) return + + try { + setUpdatingStatus(true) + await adminOrdersApi.updateStatus(orderId, statusId) + // Обновляем заказ + const updatedOrder = await adminOrdersApi.getById(orderId) + if (updatedOrder.success) { + setOrder(updatedOrder.data) + if (onOrderUpdate) { + onOrderUpdate(updatedOrder.data) + } + } + alert('Статус заказа успешно обновлен') + } catch (error) { + alert('Ошибка обновления статуса: ' + (error.response?.data?.message || error.message)) + } finally { + setUpdatingStatus(false) + } + } + + const handleUpdatePaymentStatus = async (orderId, paymentStatusId) => { + if (!canEditOrders) return + + try { + setUpdatingStatus(true) + await adminOrdersApi.updatePaymentStatus(orderId, paymentStatusId) + // Обновляем заказ + const updatedOrder = await adminOrdersApi.getById(orderId) + if (updatedOrder.success) { + setOrder(updatedOrder.data) + if (onOrderUpdate) { + onOrderUpdate(updatedOrder.data) + } + } + alert('Статус оплаты успешно обновлен') + } catch (error) { + alert('Ошибка обновления статуса оплаты: ' + (error.response?.data?.message || error.message)) + } finally { + setUpdatingStatus(false) + } + } + + const handleConfirmPayment = async () => { + const currentOrder = order || orderProp + if (!canEditOrders || !currentOrder) return + + // Находим статус "paid" (оплачен) + const paidStatus = paymentStatuses.find(s => s.slug === 'paid') + if (!paidStatus) { + alert('Статус "Оплачен" не найден') + return + } + + await handleUpdatePaymentStatus(currentOrder.id, paidStatus.id) + } + + const formatDateOnly = (dateString) => { + if (!dateString) return '—' + const date = new Date(dateString) + return date.toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric' + }) + } + + const formatTimeOnly = (dateString) => { + if (!dateString) return '—' + const date = new Date(dateString) + return date.toLocaleTimeString('ru-RU', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' + }) + } + + const formatPrice = (price) => { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB' + }).format(price) + } + + const handleAddressUpdate = async (e) => { + e.preventDefault() + if (!editingAddress?.id) return + + const currentOrder = order || orderProp + if (!currentOrder) return + + try { + setUpdatingStatus(true) + const response = await addressesApi.update(editingAddress.id, addressForm) + if (response.success) { + // Обновляем заказ + const updatedOrder = await adminOrdersApi.getById(currentOrder.id) + if (updatedOrder.success) { + setOrder(updatedOrder.data) + if (onOrderUpdate) { + onOrderUpdate(updatedOrder.data) + } + } + setEditingAddress(null) + alert('Адрес успешно обновлен') + } + } catch (error) { + alert('Ошибка обновления адреса: ' + (error.response?.data?.message || error.message)) + } finally { + setUpdatingStatus(false) + } + } + + const handleClose = () => { + console.log('[OrderDetailsModal] handleClose вызван, orderProp:', orderProp?.id, 'order:', order?.id) + if (onClose) { + console.log('[OrderDetailsModal] Вызываем onClose') + onClose() + } else { + console.warn('[OrderDetailsModal] onClose не определен') + } + } + + // Не рендерим модальное окно, если нет заказа (проверяем orderProp, так как это источник истины) + if (!orderProp) return null + + // Используем orderProp как основной источник, если локальное состояние еще не обновилось + const currentOrder = order || orderProp + + return ( +
+
e.stopPropagation()}> +

+ {currentOrder.order_number || `#${currentOrder.id}`} +

+
+
+ Дата: {formatDateOnly(currentOrder.created_at)} {formatTimeOnly(currentOrder.created_at)} +
+
+ Сумма: {formatPrice(currentOrder.total_amount || 0)} +
+ {currentOrder.status_name && ( +
+ Статус: +
+ {currentOrder.status_name} +
+
+ )} + {currentOrder.payment_status_name && ( +
+ Оплата: +
+ {currentOrder.payment_status_name} +
+
+ )} + {currentOrder.user && ( +
+ Пользователь: { + currentOrder.user + ? `${currentOrder.user.first_name || ''} ${currentOrder.user.last_name || ''}`.trim() || currentOrder.user.email || `ID: ${currentOrder.user_id}` + : currentOrder.user_id || '—' + } +
+ )} + {currentOrder.user?.email && ( +
+ Почта: {currentOrder.user.email} +
+ )} + {currentOrder.user?.phone && ( +
+ Телефон: {currentOrder.user.phone} +
+ )} + {currentOrder.shipping_address && ( +
+ Адрес доставки: + {!editingAddress ? ( +
+ {canEditOrders && ( + + )} +
{currentOrder.shipping_address.first_name} {currentOrder.shipping_address.last_name}
+ {currentOrder.shipping_address.phone && ( +
Телефон: {currentOrder.shipping_address.phone}
+ )} +
+ {currentOrder.shipping_address.country}, {currentOrder.shipping_address.city} +
+
+ {currentOrder.shipping_address.street} + {currentOrder.shipping_address.building && `, д. ${currentOrder.shipping_address.building}`} + {currentOrder.shipping_address.apartment && `, кв. ${currentOrder.shipping_address.apartment}`} +
+ {currentOrder.shipping_address.postal_code && ( +
Индекс: {currentOrder.shipping_address.postal_code}
+ )} +
+ ) : ( +
+

Редактирование адреса

+
+
+
+ + setAddressForm({ ...addressForm, first_name: e.target.value })} + required + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} + /> +
+
+ + setAddressForm({ ...addressForm, last_name: e.target.value })} + required + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} + /> +
+
+
+ + setAddressForm({ ...addressForm, phone: e.target.value })} + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} + /> +
+
+
+ + setAddressForm({ ...addressForm, country: e.target.value })} + required + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} + /> +
+
+ + setAddressForm({ ...addressForm, city: e.target.value })} + required + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} + /> +
+
+
+ + setAddressForm({ ...addressForm, street: e.target.value })} + required + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} + /> +
+
+
+ + setAddressForm({ ...addressForm, building: e.target.value })} + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} + /> +
+
+ + setAddressForm({ ...addressForm, apartment: e.target.value })} + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} + /> +
+
+ + setAddressForm({ ...addressForm, postal_code: e.target.value })} + style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} + /> +
+
+
+ + +
+
+
+ )} +
+ )} + {currentOrder.items && currentOrder.items.length > 0 && ( +
+

Товары:

+
+ {currentOrder.items.map((item, idx) => ( +
+
+ {item.product_name || 'Товар'} + {item.product_sku && ( +
+ Артикул: {item.product_sku} +
+ )} +
+
+ Количество: + {item.quantity} +
+
+ Цена: + {formatPrice(item.price || 0)} +
+
+ Сумма: + {formatPrice(item.subtotal || (item.price || 0) * item.quantity)} +
+
+ ))} +
+
+ )} +
+ + {(() => { + console.log('[OrderDetailsModal] Рендер кнопок управления, canEditOrders:', canEditOrders, 'order:', currentOrder?.id) + return canEditOrders + })() && ( +
+ + + +
+ )} + +
+ +
+
+ + {/* Модальное окно выбора статуса заказа */} + {showStatusModal && currentOrder && (() => { + // Проверяем, оплачен ли заказ + const paidStatus = paymentStatuses.find(s => s.slug === 'paid' || s.slug === 'completed') + const isPaid = paidStatus && currentOrder.payment_status_id === paidStatus.id + + // Статусы, которые требуют оплаты + const requiresPaymentStatuses = ['shipped', 'delivered'] + + return ( +
setShowStatusModal(false)}> +
e.stopPropagation()}> +

Выберите статус заказа

+
+ {orderStatuses.map(status => { + const requiresPayment = requiresPaymentStatuses.includes(status.slug) + const isDisabled = updatingStatus || + status.id === currentOrder.status_id || + (requiresPayment && !isPaid) + + return ( + + ) + })} +
+
+ +
+
+
+ ) + })()} +
+ ) +} + +export default OrderDetailsModal diff --git a/client/src/components/admin/AdminLayout.css b/client/src/components/admin/AdminLayout.css index 1c46cbf..d32b86e 100644 --- a/client/src/components/admin/AdminLayout.css +++ b/client/src/components/admin/AdminLayout.css @@ -342,3 +342,321 @@ color: #ffffff !important; } } + +/* Общие стили для модальных окон админки */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + overflow-y: auto; +} + +.modal-content { + background: var(--bg-card); + border: 1px solid var(--border-color); + color: var(--text-primary); + border-radius: 12px; + padding: 2rem; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3); + position: relative; +} + +.modal-content h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: var(--text-primary); + font-size: 1.5rem; +} + +/* Мобильная адаптация модальных окон */ +@media (max-width: 768px) { + .modal-overlay { + padding: 0.5rem; + align-items: flex-start; + padding-top: 2rem; + } + + .modal-content { + max-width: 100%; + width: 100%; + padding: 1rem; + margin: 0; + max-height: 95vh; + border-radius: 8px; + } + + .modal-content h2 { + font-size: 1.25rem; + margin-bottom: 1rem; + } + + .form-group { + margin-bottom: 1rem; + } + + .form-group label { + font-size: 0.9rem; + } + + .form-group input, + .form-group select, + .form-group textarea { + font-size: 16px; /* Предотвращает зум на iOS */ + } + + .form-actions { + flex-direction: column; + gap: 0.75rem; + width: 100%; + } + + .form-actions button { + width: 100%; + padding: 0.75rem; + font-size: 1rem; + } +} + +/* Общие стили для модальных окон деталей заказа */ +.modal-content.order-details { + max-width: 800px; + width: 100%; +} + +.modal-content.order-details h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: #ffffff; + font-size: 1.5rem; + font-weight: 600; +} + +.modal-content.order-details .order-info { + margin-bottom: 1.5rem; +} + +.modal-content.order-details .info-row { + padding: 0.75rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: #ffffff; +} + +.modal-content.order-details .info-row strong { + color: #ffffff; + margin-right: 0.5rem; +} + +.modal-content.order-details .order-items { + margin-top: 2rem; +} + +.modal-content.order-details .order-items h3 { + margin-bottom: 1rem; + color: #ffffff; + font-size: 1.25rem; + font-weight: 600; +} + +.modal-content.order-details .order-items-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.modal-content.order-details .order-item-card { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1rem; + color: #ffffff; + backdrop-filter: blur(10px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.modal-content.order-details .order-item-name { + font-weight: 600; + margin-bottom: 0.5rem; + color: #ffffff; +} + +.modal-content.order-details .order-item-row { + display: flex; + justify-content: space-between; + margin-bottom: 0.25rem; + color: #ffffff; +} + +.modal-content.order-details .info-row-status { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.modal-content.order-details .badge-info { + background-color: rgba(51, 144, 236, 0.2); + color: #ffffff; + border: 1px solid rgba(51, 144, 236, 0.3); + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.875rem; + display: inline-block; +} + +@media (max-width: 768px) { + .modal-content.order-details { + max-width: 95vw; + padding: 1.5rem; + max-height: 85vh; + } + + .modal-content.order-details .info-row { + flex-direction: column; + gap: 0.5rem; + } + + .modal-content.order-details .info-row strong { + width: 100%; + margin-bottom: 0.25rem; + } +} + +/* Стили для кнопок управления заказом */ +.order-actions-row { + display: flex; + gap: 0.75rem; + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); + flex-wrap: wrap; +} + +.order-action-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + font-size: 0.9rem; + color: #ffffff; +} + +.order-action-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.order-action-btn:active:not(:disabled) { + transform: translateY(0); +} + +.order-action-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.order-action-btn svg { + flex-shrink: 0; +} + +/* Модальное окно выбора статуса */ +.status-select-modal { + max-width: 500px; +} + +.status-select-modal h2 { + margin-top: 0; + margin-bottom: 1.5rem; + color: #ffffff; +} + +.status-list { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.status-option { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + text-align: left; + width: 100%; +} + +.status-option:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.15); + transform: translateY(-1px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +.status-option:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.status-color-dot { + width: 12px; + height: 12px; + border-radius: 50%; + flex-shrink: 0; +} + +.status-name { + font-weight: 600; + color: #ffffff; + margin-bottom: 0.25rem; +} + +.status-description { + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.7); +} + +@media (max-width: 768px) { + .order-actions-row { + flex-direction: column; + gap: 0.5rem; + } + + .order-action-btn { + width: 100%; + justify-content: center; + padding: 0.625rem 1rem; + } + + .status-select-modal { + max-width: 100%; + } + + .status-option { + padding: 0.75rem; + font-size: 0.9rem; + } +} diff --git a/client/src/pages/MyOrders.css b/client/src/pages/MyOrders.css new file mode 100644 index 0000000..f247d49 --- /dev/null +++ b/client/src/pages/MyOrders.css @@ -0,0 +1,43 @@ +/* Используем стили из admin/Orders.css */ +.admin-orders { + max-width: 1400px; +} + +.loading { + text-align: center; + padding: 3rem; + color: #ffffff; + font-size: 1.1rem; +} + +.empty-state { + text-align: center; + padding: 4rem 2rem; + color: #ffffff; +} + +.empty-state p { + font-size: 1.2rem; + margin-bottom: 1.5rem; + color: rgba(255, 255, 255, 0.7); +} + +.btn-primary { + background: #3390ec; + color: #ffffff; + border: 1px solid #3390ec; + padding: 0.5rem 1rem; + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + transition: all 0.2s; + text-decoration: none; + display: inline-block; +} + +.btn-primary:hover { + background: #2a7bc7; + border-color: #2a7bc7; +} + +/* Мобильная адаптация уже есть в admin/Orders.css */ diff --git a/client/src/pages/MyOrders.jsx b/client/src/pages/MyOrders.jsx new file mode 100644 index 0000000..9e1c819 --- /dev/null +++ b/client/src/pages/MyOrders.jsx @@ -0,0 +1,289 @@ +import { useState, useEffect } from 'react' +import { ordersApi } from '../api/orders' +import OrderDetailsModal from '../components/OrderDetailsModal' +import { hasOrderEditAccess } from '../utils/auth' +import './MyOrders.css' +import '../pages/admin/Orders.css' +import '../pages/Orders.css' + +function useWindowWidth() { + const [windowWidth, setWindowWidth] = useState(window.innerWidth) + + useEffect(() => { + const handleResize = () => { + setWindowWidth(window.innerWidth) + } + + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + return windowWidth +} + +function MyOrders() { + const [orders, setOrders] = useState([]) + const [loading, setLoading] = useState(true) + const [page, setPage] = useState(1) + const [pagination, setPagination] = useState(null) + const [selectedOrder, setSelectedOrder] = useState(null) + const [canEditOrders, setCanEditOrders] = useState(false) + const windowWidth = useWindowWidth() + const isMobile = windowWidth <= 768 + + useEffect(() => { + // Проверяем права доступа при монтировании + const hasAccess = hasOrderEditAccess() + setCanEditOrders(hasAccess) + }, []) + + useEffect(() => { + loadOrders() + }, [page]) + + // Функция для определения приоритета статуса заказа + const getStatusPriority = (order) => { + const statusSlug = order.status_slug?.toLowerCase() || '' + + // Максимальный приоритет: заказы в обработке + if (statusSlug === 'processing' || statusSlug === 'pending') { + return 1 + } + + // Минимальный приоритет: завершенные или возвращенные заказы + if (statusSlug === 'delivered' || statusSlug === 'completed' || + statusSlug === 'cancelled' || statusSlug === 'refunded') { + return 3 + } + + // Средний приоритет: остальные статусы (shipped и т.д.) + return 2 + } + + // Функция сортировки заказов по статусу + const sortOrdersByStatus = (ordersList) => { + return [...ordersList].sort((a, b) => { + const priorityA = getStatusPriority(a) + const priorityB = getStatusPriority(b) + + // Сначала сортируем по приоритету статуса + if (priorityA !== priorityB) { + return priorityA - priorityB + } + + // Если приоритет одинаковый, сортируем по дате создания (новые первыми) + const dateA = new Date(a.created_at || 0).getTime() + const dateB = new Date(b.created_at || 0).getTime() + return dateB - dateA + }) + } + + const loadOrders = async () => { + try { + setLoading(true) + const response = await ordersApi.getMyOrders({ page, limit: 20 }) + if (response && response.success) { + const ordersData = Array.isArray(response.data) ? response.data : [] + // Сортируем заказы по статусу после получения данных + const sortedOrders = sortOrdersByStatus(ordersData) + setOrders(sortedOrders) + setPagination(response.pagination) + } else { + setOrders([]) + setPagination(null) + } + } catch (error) { + console.error('Ошибка загрузки заказов:', error) + setOrders([]) + setPagination(null) + } finally { + setLoading(false) + } + } + + const handleView = async (id) => { + try { + const response = await ordersApi.getById(id) + if (response.success) { + console.log('[MyOrders] Заказ загружен, устанавливаем selectedOrder:', response.data.id) + setSelectedOrder(response.data) + } else { + alert('Ошибка загрузки заказа: ' + (response.message || 'Неизвестная ошибка')) + } + } catch (error) { + alert('Ошибка загрузки заказа: ' + (error.response?.data?.message || error.message)) + } + } + + const handleCloseModal = () => { + console.log('[MyOrders] handleCloseModal вызван') + setSelectedOrder(null) + console.log('[MyOrders] selectedOrder установлен в null') + } + + const formatDate = (dateString) => { + if (!dateString) return '—' + return new Date(dateString).toLocaleString('ru-RU') + } + + const formatPrice = (price) => { + return new Intl.NumberFormat('ru-RU', { + style: 'currency', + currency: 'RUB' + }).format(price) + } + + + return ( +
+
+
+

Мои заказы

+
+ + {loading ? ( +
Загрузка...
+ ) : orders.length === 0 ? ( +
+

У вас пока нет заказов

+ + Перейти к товарам + +
+ ) : ( + <> + {isMobile ? ( + /* Карточки для мобильных устройств */ +
+ {orders.map(order => ( +
+
+
+

{order.order_number || order.id}

+ {formatDate(order.created_at)} +
+
+ + {order.status_name || order.status_id || '—'} + +
+
+
+
+
+ Сумма: + {formatPrice(order.total_amount || 0)} +
+ {order.items && order.items.length > 0 && ( +
+ Товаров: + {order.items.length} +
+ )} +
+ +
+
+ ))} +
+ ) : ( + /* Таблица для десктопа */ + + + + + + + + + + + + + {orders.map(order => ( + + + + + + + + + ))} + +
Номер заказаСуммаСтатусОплатаДатаДействия
+ {order.order_number || `#${order.id}`} + {formatPrice(order.total_amount || 0)} + {order.status_name ? ( + + {order.status_name} + + ) : ( + — + )} + + {order.payment_status_name ? ( + + {order.payment_status_name} + + ) : ( + — + )} + {formatDate(order.created_at)} + +
+ )} + + {pagination && ( +
+ + Страница {pagination.page} из {pagination.totalPages} + +
+ )} + + )} +
+ + { + setSelectedOrder(updatedOrder) + // Обновляем заказ в списке + setOrders(orders.map(o => o.id === updatedOrder.id ? updatedOrder : o)) + }} + /> +
+ ) +} + +export default MyOrders diff --git a/client/src/pages/Orders.css b/client/src/pages/Orders.css index 65828be..d7e1ce9 100644 --- a/client/src/pages/Orders.css +++ b/client/src/pages/Orders.css @@ -195,7 +195,7 @@ .orders-page .badge-info { background-color: rgba(51, 144, 236, 0.2); - color: #3390ec; + color: #ffffff; border: 1px solid rgba(51, 144, 236, 0.3); } @@ -414,7 +414,7 @@ .info-row strong { margin-right: 0.5rem; - color: var(--text-primary); + color: #ffffff !important; } .info-row:last-child { @@ -424,7 +424,7 @@ .info-row strong { display: inline-block; min-width: 150px; - color: #333; + color: #ffffff; } @media (max-width: 768px) { @@ -492,13 +492,15 @@ } .order-item-card { - background: var(--bg-tertiary); - border: 1px solid var(--border-color); - border-radius: 6px; - padding: 0.75rem; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1rem; position: relative; overflow: hidden; - color: var(--text-primary); + color: #ffffff; + backdrop-filter: blur(10px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); } /* Фоновая картинка убрана */ @@ -511,24 +513,25 @@ .order-item-name { margin-bottom: 0.5rem; padding-bottom: 0.5rem; - border-bottom: 1px solid var(--border-color); + border-bottom: 1px solid rgba(255, 255, 255, 0.15); } .order-item-name strong { display: block; margin-bottom: 0.25rem; - color: var(--text-primary); + color: #ffffff; font-size: 0.9rem; + font-weight: 600; } .order-item-name .text-muted { font-size: 0.8rem; - color: var(--text-secondary); + color: rgba(255, 255, 255, 0.8); } .order-item-name small { font-size: 0.75rem; - color: var(--text-muted); + color: rgba(255, 255, 255, 0.7); } .order-item-details { @@ -545,16 +548,16 @@ } .order-item-row span:first-child { - color: #666; + color: rgba(255, 255, 255, 0.9); } .order-item-row span:last-child { - color: #333; + color: #ffffff; font-weight: 500; } .order-item-row strong { - color: #333; + color: #ffffff; font-weight: 600; } @@ -654,10 +657,13 @@ max-width: 500px; } +.status-select-modal h2, .status-select-modal h3 { margin-top: 0; margin-bottom: 1.5rem; - color: var(--text-primary); + color: #ffffff; + font-size: 1.5rem; + font-weight: 600; } .status-list { @@ -672,21 +678,22 @@ align-items: center; gap: 0.75rem; padding: 1rem; - border: 2px solid var(--border-color); + border: 2px solid rgba(255, 255, 255, 0.2); border-radius: 6px; - background: var(--bg-card); + background: rgba(255, 255, 255, 0.1); cursor: pointer; transition: all 0.2s; text-align: left; font-size: 1rem; - color: var(--text-primary); + color: #ffffff; + width: 100%; } .status-option:hover:not(:disabled) { - border-color: var(--border-light); + border-color: rgba(255, 255, 255, 0.3); transform: translateX(4px); - box-shadow: var(--shadow-sm); - background: var(--bg-tertiary); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + background: rgba(255, 255, 255, 0.15); } .status-option.active { @@ -705,6 +712,26 @@ flex-shrink: 0; } +.status-name { + font-weight: 500; +} + +.status-description { + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.7); + margin-top: 0.25rem; +} + +.status-name { + font-weight: 500; +} + +.status-description { + font-size: 0.875rem; + color: rgba(255, 255, 255, 0.7); + margin-top: 0.25rem; +} + .modal-actions { display: flex; justify-content: flex-end; @@ -839,19 +866,23 @@ align-items: center; justify-content: space-between; gap: 0.5rem; + flex-wrap: wrap; } .info-row-status strong { margin-bottom: 0 !important; flex-shrink: 0; + min-width: fit-content; } .info-row-status .badge { - margin-left: auto; font-size: clamp(0.75rem, 2.5vw, 0.9rem); padding: 0.35rem 0.75rem; - max-width: 100%; + max-width: calc(100% - 100px); margin-top: 0 !important; + flex-shrink: 1; + word-wrap: break-word; + overflow-wrap: break-word; } .form-actions { diff --git a/client/src/pages/Orders.jsx b/client/src/pages/Orders.jsx index e64cd92..d47ba6f 100644 --- a/client/src/pages/Orders.jsx +++ b/client/src/pages/Orders.jsx @@ -55,10 +55,13 @@ function Orders() { }) useEffect(() => { + console.log('[Orders] useEffect вызван, isAuthenticated:', isAuthenticated(), 'page:', page) if (isAuthenticated()) { + console.log('[Orders] Пользователь авторизован, вызываем checkAdminAndLoadOrders') checkAdminAndLoadOrders() loadStatuses() } else { + console.log('[Orders] Пользователь не авторизован') setLoading(false) } }, [page]) @@ -81,73 +84,143 @@ function Orders() { } } + // Функция для определения приоритета статуса заказа + const getStatusPriority = (order) => { + const statusSlug = order.status_slug?.toLowerCase() || '' + + // Максимальный приоритет: заказы в обработке + if (statusSlug === 'processing' || statusSlug === 'pending') { + return 1 + } + + // Минимальный приоритет: завершенные или возвращенные заказы + if (statusSlug === 'delivered' || statusSlug === 'completed' || + statusSlug === 'cancelled' || statusSlug === 'refunded') { + return 3 + } + + // Средний приоритет: остальные статусы (shipped и т.д.) + return 2 + } + + // Функция сортировки заказов по статусу + const sortOrdersByStatus = (ordersList) => { + return [...ordersList].sort((a, b) => { + const priorityA = getStatusPriority(a) + const priorityB = getStatusPriority(b) + + // Сначала сортируем по приоритету статуса + if (priorityA !== priorityB) { + return priorityA - priorityB + } + + // Если приоритет одинаковый, сортируем по дате создания (новые первыми) + const dateA = new Date(a.created_at || 0).getTime() + const dateB = new Date(b.created_at || 0).getTime() + return dateB - dateA + }) + } + const checkAdminAndLoadOrders = async () => { + console.log('[Orders] checkAdminAndLoadOrders вызван, page:', page) + if (!isAuthenticated()) { + console.log('[Orders] Пользователь не авторизован') setLoading(false) return } try { setLoading(true) + console.log('[Orders] Начало загрузки заказов') // Проверяем права на редактирование и доступ const hasEditAccess = hasOrderEditAccess() const hasAccess = hasOrderAccess() + const user = getUser() + const roleSlug = user?.role?.slug || user?.role_slug - // Всегда пытаемся загрузить все заказы (для админов, сотрудников, менеджеров) - // Если получится - пользователь имеет доступ, если нет - обычный пользователь - try { - const adminResponse = await adminOrdersApi.getAll({ page, limit: 20 }) - if (adminResponse.success) { - setOrders(adminResponse.data || []) - setPagination(adminResponse.pagination) - setIsAdmin(hasAccess) - setCanEditOrders(hasEditAccess) - setLoading(false) - return - } - } catch (adminError) { - // Если ошибка 403 или 401 - пользователь не имеет доступа к просмотру всех заказов - if (adminError.response?.status === 403 || adminError.response?.status === 401) { - setIsAdmin(false) - setCanEditOrders(false) - // Пробуем загрузить свои заказы - try { - const userResponse = await ordersApi.getMyOrders({ page, limit: 20 }) - if (userResponse.success) { - setOrders(userResponse.data || []) - setPagination(userResponse.pagination) - } else { - setOrders([]) - setPagination(null) - } - } catch (userError) { - console.error('Ошибка загрузки своих заказов:', userError) - setOrders([]) - setPagination(null) + console.log('[Orders] Права доступа:', { hasEditAccess, hasAccess, roleSlug, user }) + + // Если пользователь имеет доступ ко всем заказам (админ/сотрудник) - используем admin API + // НО только если это действительно админ/сотрудник, а не обычный клиент + if (hasAccess && roleSlug && roleSlug !== 'customer') { + console.log('[Orders] Пользователь имеет доступ ко всем заказам (не customer), используем admin API') + try { + const adminResponse = await adminOrdersApi.getAll({ page, limit: 20 }) + console.log('[Orders] Admin API ответ:', adminResponse) + if (adminResponse.success) { + console.log('[Orders] Устанавливаем заказы из admin API:', adminResponse.data?.length) + const ordersData = adminResponse.data || [] + // Сортируем заказы по статусу после получения данных + const sortedOrders = sortOrdersByStatus(ordersData) + setOrders(sortedOrders) + setPagination(adminResponse.pagination) + setIsAdmin(true) + setCanEditOrders(hasEditAccess) + setLoading(false) + return } - setLoading(false) - return - } else { - // Другие ошибки - пробуем загрузить свои заказы - console.warn('Ошибка загрузки всех заказов:', adminError) + } catch (adminError) { + console.error('[Orders] Ошибка загрузки всех заказов (admin):', adminError) + console.error('[Orders] Переходим к загрузке своих заказов как fallback') + // Если ошибка - пробуем загрузить свои заказы как fallback } + } else { + console.log('[Orders] Пользователь - обычный клиент или нет доступа, загружаем свои заказы') } - // Если не удалось загрузить через admin API - загружаем только свои заказы + // Для обычных клиентов или если admin API не сработал - загружаем свои заказы + console.log('[Orders] Загружаем свои заказы через ordersApi.getMyOrders, page:', page) try { const userResponse = await ordersApi.getMyOrders({ page, limit: 20 }) - if (userResponse.success) { - setOrders(userResponse.data || []) - setPagination(userResponse.pagination) - setIsAdmin(false) - setCanEditOrders(false) + console.log('[Orders] === ПОЛУЧЕН ОТВЕТ ОТ API ===') + console.log('[Orders] userResponse:', userResponse) + console.log('[Orders] userResponse.success:', userResponse?.success) + console.log('[Orders] userResponse.data:', userResponse?.data) + console.log('[Orders] userResponse.data isArray:', Array.isArray(userResponse?.data)) + console.log('[Orders] userResponse.data длина:', userResponse?.data?.length) + + if (userResponse && userResponse.success) { + // Стандартный формат ответа: { success: true, data: [...], pagination: {...} } + // data уже является массивом заказов + const ordersData = Array.isArray(userResponse.data) ? userResponse.data : [] + + console.log('[Orders] === ИТОГОВЫЕ ДАННЫЕ ===') + console.log('[Orders] Заказов для установки:', ordersData.length) + console.log('[Orders] Первые 2 заказа:', ordersData.slice(0, 2).map(o => ({ id: o.id, order_number: o.order_number }))) + console.log('[Orders] Pagination:', userResponse.pagination) + + if (ordersData.length > 0) { + console.log('[Orders] ✓ Устанавливаем заказы в state:', ordersData.length) + // Сортируем заказы по статусу после получения данных + const sortedOrders = sortOrdersByStatus(ordersData) + setOrders(sortedOrders) + setPagination(userResponse.pagination || null) + setIsAdmin(false) + // Проверяем права доступа - если админ/сотрудник, то может редактировать даже свои заказы + setCanEditOrders(hasEditAccess) + console.log('[Orders] ✓ Заказы успешно установлены в state, canEditOrders:', hasEditAccess) + } else { + console.warn('[Orders] ✗ Массив заказов пуст') + setOrders([]) + setPagination(userResponse.pagination || null) + // Проверяем права доступа даже если заказов нет + setCanEditOrders(hasEditAccess) + } } else { + console.warn('[Orders] ✗ API вернул success: false или нет success') + console.warn('[Orders] Ответ:', userResponse) setOrders([]) setPagination(null) + // Проверяем права доступа даже если заказов нет + setCanEditOrders(hasEditAccess) } } catch (userError) { - console.error('Ошибка загрузки своих заказов:', userError) + console.error('✗ Ошибка загрузки своих заказов:', userError) + console.error('Детали ошибки:', userError.response?.data || userError.message) + console.error('Статус ошибки:', userError.response?.status) + console.error('Полный ответ ошибки:', userError.response) setOrders([]) setPagination(null) } @@ -368,7 +441,7 @@ function Orders() {
-

Заказ {order.order_number || order.id}

+

{order.order_number || order.id}

{formatDate(order.created_at)} {order.user && (
@@ -519,377 +592,102 @@ function Orders() { {selectedOrder && (
setSelectedOrder(null)}>
e.stopPropagation()}> -

Детали заказа

-
- {isMobile ? ( -
- - - - - - - {selectedOrder.order_number || selectedOrder.id} -
- ) : ( -
Номер:{selectedOrder.order_number || selectedOrder.id}
- )} -
- {selectedOrder.user && ( -
-
- {isMobile ? ( - <> -
- - - - - { - (selectedOrder.user.first_name || selectedOrder.user.last_name) - ? `${selectedOrder.user.first_name || ''} ${selectedOrder.user.last_name || ''}`.trim() - : selectedOrder.user.email || `ID: ${selectedOrder.user_id}` - } -
- {selectedOrder.user?.email && ( -
- - - - - {selectedOrder.user.email} -
- )} - {selectedOrder.user?.telegram_username && ( -
- Telegram: @{selectedOrder.user.telegram_username} -
- )} - - ) : ( - <> -
- Пользователь: { - (selectedOrder.user.first_name || selectedOrder.user.last_name) - ? `${selectedOrder.user.first_name || ''} ${selectedOrder.user.last_name || ''}`.trim() - : selectedOrder.user.email || `ID: ${selectedOrder.user_id}` - } -
- {selectedOrder.user?.email && ( - <> - {isMobile ? ( -
- - - - - {selectedOrder.user.email} -
- ) : ( -
- Email: {selectedOrder.user.email} -
- )} - - )} - {selectedOrder.user?.telegram_username && ( -
- Telegram: @{selectedOrder.user.telegram_username} -
- )} - - )} -
-
- )} -
- {isMobile ? ( -
- - - - - {formatDateOnly(selectedOrder.created_at)} {formatTimeOnly(selectedOrder.created_at)} -
- ) : ( - <> - Дата создания: {formatDate(selectedOrder.created_at)} - - )} -
+

+ {selectedOrder.order_number || `#${selectedOrder.id}`} +

- {selectedOrder.shipping_address && ( -
- Адрес доставки: - {!editingAddress ? ( -
- {canEditOrders && ( - - )} -
{selectedOrder.shipping_address.first_name} {selectedOrder.shipping_address.last_name}
- {selectedOrder.shipping_address.phone && ( -
Телефон: {selectedOrder.shipping_address.phone}
- )} -
- {selectedOrder.shipping_address.country}, {selectedOrder.shipping_address.city} -
-
- {selectedOrder.shipping_address.street} - {selectedOrder.shipping_address.building && `, д. ${selectedOrder.shipping_address.building}`} - {selectedOrder.shipping_address.apartment && `, кв. ${selectedOrder.shipping_address.apartment}`} -
- {selectedOrder.shipping_address.postal_code && ( -
Индекс: {selectedOrder.shipping_address.postal_code}
- )} -
- ) : ( -
-

Редактирование адреса

-
{ - e.preventDefault() - if (!editingAddress.id) return - - try { - setUpdatingStatus(true) - const response = await addressesApi.update(editingAddress.id, addressForm) - if (response.success) { - // Обновляем заказ - const updatedOrder = await adminOrdersApi.getById(selectedOrder.id) - if (updatedOrder.success) { - setSelectedOrder(updatedOrder.data) - } - setEditingAddress(null) - alert('Адрес успешно обновлен') - } - } catch (error) { - alert('Ошибка обновления адреса: ' + (error.response?.data?.message || error.message)) - } finally { - setUpdatingStatus(false) - } - }}> -
-
- - setAddressForm({ ...addressForm, first_name: e.target.value })} - required - style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} - /> -
-
- - setAddressForm({ ...addressForm, last_name: e.target.value })} - required - style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} - /> -
-
-
- - setAddressForm({ ...addressForm, phone: e.target.value })} - style={{ width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }} - /> -
-
-
- - setAddressForm({ ...addressForm, country: e.target.value })} - required - style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} - /> -
-
- - setAddressForm({ ...addressForm, city: e.target.value })} - required - style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} - /> -
-
-
- - setAddressForm({ ...addressForm, street: e.target.value })} - required - style={{ width: '100%', padding: '0.5rem', border: '1px solid #ddd', borderRadius: '4px' }} - /> -
-
-
- - setAddressForm({ ...addressForm, building: e.target.value })} - style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} - /> -
-
- - setAddressForm({ ...addressForm, apartment: e.target.value })} - style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} - /> -
-
- - setAddressForm({ ...addressForm, postal_code: e.target.value })} - style={{ width: '100%', padding: '0.5rem', border: '1px solid var(--border-color, #2f2f2f)', borderRadius: '4px', background: 'var(--bg-color, #1a1a1a)', color: 'var(--text-primary, #ffffff)' }} - /> -
-
-
- - -
-
-
- )} -
- )} +
+ Дата: {formatDateOnly(selectedOrder.created_at)} {formatTimeOnly(selectedOrder.created_at)} +
Сумма: {formatPrice(selectedOrder.total_amount || 0)}
-
- Статус: - - {selectedOrder.status_name || selectedOrder.status_id || '—'} - -
- {selectedOrder.payment_status_name && ( -
- Оплата: - - {selectedOrder.payment_status_name} - -
- )} + {selectedOrder.status_name && ( +
+ Статус: +
+ {selectedOrder.status_name} +
+
+ )} + {selectedOrder.payment_status_name && ( +
+ Оплата: +
+ {selectedOrder.payment_status_name} +
+
+ )} + {selectedOrder.user && ( +
+ Пользователь: { + selectedOrder.user + ? `${selectedOrder.user.first_name || ''} ${selectedOrder.user.last_name || ''}`.trim() || selectedOrder.user.email || `ID: ${selectedOrder.user_id}` + : selectedOrder.user_id || '—' + } +
+ )} + {selectedOrder.user?.email && ( +
+ Почта: {selectedOrder.user.email} +
+ )} + {selectedOrder.user?.phone && ( +
+ Телефон: {selectedOrder.user.phone} +
+ )} + {selectedOrder.shipping_address && ( +
+ Адрес доставки: +
+
{selectedOrder.shipping_address.first_name} {selectedOrder.shipping_address.last_name}
+ {selectedOrder.shipping_address.phone && ( +
Телефон: {selectedOrder.shipping_address.phone}
+ )} +
+ {selectedOrder.shipping_address.country}, {selectedOrder.shipping_address.city} +
+
+ {selectedOrder.shipping_address.street} + {selectedOrder.shipping_address.building && `, д. ${selectedOrder.shipping_address.building}`} + {selectedOrder.shipping_address.apartment && `, кв. ${selectedOrder.shipping_address.apartment}`} +
+ {selectedOrder.shipping_address.postal_code && ( +
Индекс: {selectedOrder.shipping_address.postal_code}
+ )} +
+
+ )} {selectedOrder.items && selectedOrder.items.length > 0 && (
-

Товары:

- {isMobile ? ( -
- {selectedOrder.items.map((item, idx) => { - return ( -
-
- {item.product_name || 'Товар'} - {item.product_sku && Артикул: {item.product_sku}} -
-
-
- Количество: - {item.quantity} +

Товары:

+
+ {selectedOrder.items.map((item, idx) => ( +
+
+ {item.product_name || 'Товар'} + {item.product_sku && ( +
+ Артикул: {item.product_sku}
-
- Цена: - {formatPrice(item.price || 0)} -
-
- Сумма: - {formatPrice(item.subtotal || (item.price || 0) * item.quantity)} -
-
+ )}
- ); - })} -
- ) : ( - - - - - - - - - - - - {selectedOrder.items.map((item, idx) => ( - - - - - - - - ))} - -
ТоварАртикулКоличествоЦенаСумма
{item.product_name || 'Товар'}{item.product_sku || '—'}{item.quantity}{formatPrice(item.price || 0)}{formatPrice(item.subtotal || (item.price || 0) * item.quantity)}
- )} +
+ Количество: + {item.quantity} +
+
+ Цена: + {formatPrice(item.price || 0)} +
+
+ Сумма: + {formatPrice(item.subtotal || (item.price || 0) * item.quantity)} +
+
+ ))} +
)}
@@ -934,17 +732,14 @@ function Orders() { {/* Модальное окно выбора статуса заказа */} {showStatusModal && selectedOrder && (() => { - // Проверяем, оплачен ли заказ const paidStatus = paymentStatuses.find(s => s.slug === 'paid' || s.slug === 'completed') const isPaid = paidStatus && selectedOrder.payment_status_id === paidStatus.id - - // Статусы, которые требуют оплаты const requiresPaymentStatuses = ['shipped', 'delivered'] return (
setShowStatusModal(false)}>
e.stopPropagation()}> -

Выберите статус заказа

+

Выберите статус заказа

{orderStatuses.map(status => { const requiresPayment = requiresPaymentStatuses.includes(status.slug) @@ -958,9 +753,9 @@ function Orders() { className={`status-option ${status.id === selectedOrder.status_id ? 'active' : ''}`} style={{ borderColor: status.color || '#3390ec', - backgroundColor: status.id === selectedOrder.status_id ? (status.color || '#3390ec') : 'var(--bg-card, #1e1e1e)', - color: status.id === selectedOrder.status_id ? '#ffffff' : 'var(--text-primary, #ffffff)', - border: status.id === selectedOrder.status_id ? 'none' : '1px solid var(--border-color, #2f2f2f)' + backgroundColor: status.id === selectedOrder.status_id ? (status.color || '#3390ec') : 'rgba(255, 255, 255, 0.1)', + color: status.id === selectedOrder.status_id ? '#ffffff' : '#ffffff', + border: status.id === selectedOrder.status_id ? 'none' : '1px solid rgba(255, 255, 255, 0.2)' }} onClick={() => { if (!isDisabled) { @@ -972,7 +767,12 @@ function Orders() { title={requiresPayment && !isPaid ? 'Заказ должен быть оплачен' : ''} > - {status.name} +
+
{status.name}
+ {status.description && ( +
{status.description}
+ )} +
{requiresPayment && !isPaid && ( (требуется оплата) diff --git a/client/src/pages/ProductDetail.css b/client/src/pages/ProductDetail.css index 501e71a..3131ed0 100644 --- a/client/src/pages/ProductDetail.css +++ b/client/src/pages/ProductDetail.css @@ -200,6 +200,9 @@ .product-actions { border-top: 1px solid var(--border-color); padding-top: 2rem; + display: flex; + flex-direction: column; + gap: 0.75rem; } .quantity-selector { @@ -251,6 +254,12 @@ width: 100%; padding: 1rem; font-size: 1.125rem; + margin-bottom: 0.75rem; +} + +.product-actions .btn-secondary { + width: 100%; + margin-top: 0; } .error-container { @@ -325,9 +334,22 @@ font-size: 1rem; } - .add-to-cart-btn { - padding: 0.875rem; + .product-actions { + padding-top: 1.5rem; + gap: 0.75rem; + } + + .product-actions .btn { + width: 100%; + padding: 0.875rem 1rem; font-size: 1rem; + margin: 0; + } + + .add-to-cart-btn { + padding: 0.875rem 1rem; + font-size: 1rem; + margin-bottom: 0; } .quantity-btn { diff --git a/client/src/pages/ProductDetail.jsx b/client/src/pages/ProductDetail.jsx index 33ad082..54ad37a 100644 --- a/client/src/pages/ProductDetail.jsx +++ b/client/src/pages/ProductDetail.jsx @@ -403,7 +403,6 @@ function ProductDetail() { diff --git a/client/src/pages/Profile.jsx b/client/src/pages/Profile.jsx index 3da861c..9530dd5 100644 --- a/client/src/pages/Profile.jsx +++ b/client/src/pages/Profile.jsx @@ -664,14 +664,16 @@ function Profile() {

Смена пароля

- + setPasswordForm({ ...passwordForm, old_password: e.target.value })} - placeholder="Введите текущий пароль" - required + placeholder="Введите текущий пароль (если он установлен)" /> + + Если пароль не был установлен (например, вход через Telegram), оставьте поле пустым. +
diff --git a/client/src/pages/admin/Categories.css b/client/src/pages/admin/Categories.css index c12f6a8..472b4d3 100644 --- a/client/src/pages/admin/Categories.css +++ b/client/src/pages/admin/Categories.css @@ -3,3 +3,10 @@ } /* Используем общие стили из Products.css */ + +/* Мобильная адаптация */ +@media (max-width: 768px) { + .admin-categories { + max-width: 100%; + } +} diff --git a/client/src/pages/admin/Categories.jsx b/client/src/pages/admin/Categories.jsx index 2df2ee9..dda6921 100644 --- a/client/src/pages/admin/Categories.jsx +++ b/client/src/pages/admin/Categories.jsx @@ -7,6 +7,7 @@ function Categories() { const [loading, setLoading] = useState(true) const [showModal, setShowModal] = useState(false) const [editingCategory, setEditingCategory] = useState(null) + const [slugManuallyEdited, setSlugManuallyEdited] = useState(false) const [formData, setFormData] = useState({ name: '', slug: '', @@ -42,8 +43,37 @@ function Categories() { } } + const transliterate = (text) => { + const cyrillicToLatin = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', + 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sch', + 'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + } + + return text.split('').map(char => cyrillicToLatin[char] || char).join('') + } + + const generateSlug = (name) => { + if (!name) return '' + return transliterate(name) + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Удаляем специальные символы + .replace(/[\s_]+/g, '-') // Заменяем пробелы и подчеркивания на дефисы + .replace(/-+/g, '-') // Удаляем множественные дефисы + .replace(/^-+|-+$/g, '') // Удаляем дефисы в начале и конце + } + const handleEdit = (category) => { setEditingCategory(category) + setSlugManuallyEdited(false) setFormData({ name: category.name || '', slug: category.slug || '', @@ -56,6 +86,7 @@ function Categories() { const handleCreate = () => { setEditingCategory(null) + setSlugManuallyEdited(false) setFormData({ name: '', slug: '', @@ -163,21 +194,21 @@ function Categories() { {categories.map(category => ( - {category.id} - {category.name} - {category.slug} - + {category.id} + {category.name} + {category.slug} + {category.parent_id ? categories.find(c => c.id === category.parent_id)?.name || category.parent_id : '—' } - + {category.is_active ? 'Активна' : 'Неактивна'} - + @@ -201,16 +232,26 @@ function Categories() { setFormData({ ...formData, name: e.target.value })} + onChange={(e) => { + const newName = e.target.value + setFormData({ + ...formData, + name: newName, + slug: slugManuallyEdited ? formData.slug : generateSlug(newName) + }) + }} required />
-
+
setFormData({ ...formData, slug: e.target.value })} + onChange={(e) => { + setSlugManuallyEdited(true) + setFormData({ ...formData, slug: e.target.value }) + }} required />
diff --git a/client/src/pages/admin/Dashboard.css b/client/src/pages/admin/Dashboard.css index 8c81d56..938d172 100644 --- a/client/src/pages/admin/Dashboard.css +++ b/client/src/pages/admin/Dashboard.css @@ -77,3 +77,33 @@ padding: 2rem; color: var(--text-secondary); } + +/* Мобильная адаптация */ +@media (max-width: 768px) { + .dashboard { + max-width: 100%; + } + + .dashboard h1 { + font-size: 1.5rem; + margin-bottom: 1.5rem; + } + + .stats-grid { + grid-template-columns: 1fr; + gap: 1rem; + } + + .stat-card { + padding: 1rem; + } + + .stat-icon { + width: 48px; + height: 48px; + } + + .stat-value { + font-size: 1.5rem; + } +} diff --git a/client/src/pages/admin/Orders.css b/client/src/pages/admin/Orders.css index d9fab2b..503de75 100644 --- a/client/src/pages/admin/Orders.css +++ b/client/src/pages/admin/Orders.css @@ -17,13 +17,14 @@ .info-row { padding: 0.75rem 0; - border-bottom: 1px solid #eee; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + color: #ffffff; } .info-row strong { display: inline-block; - width: 150px; - color: #2c3e50; + margin-right: 0.5rem; + color: #ffffff; } .order-items { @@ -32,5 +33,150 @@ .order-items h3 { margin-bottom: 1rem; - color: #2c3e50; + color: #ffffff; } + +.order-items-list { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.order-item-card { + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1rem; + color: #ffffff; +} + +.order-item-name { + font-weight: 600; + margin-bottom: 0.5rem; + color: #ffffff; +} + +.order-item-row { + display: flex; + justify-content: space-between; + margin-bottom: 0.25rem; + color: #ffffff; +} + +.badge-info { + background-color: rgba(51, 144, 236, 0.2); + color: #ffffff; + border: 1px solid rgba(51, 144, 236, 0.3); + padding: 0.25rem 0.75rem; + border-radius: 4px; + font-size: 0.875rem; + display: inline-block; +} + +/* Скрываем/показываем элементы в зависимости от размера экрана */ +.desktop-only { + display: inline; +} + +.mobile-only { + display: none; +} + +@media (max-width: 768px) { + .desktop-only { + display: none !important; + } + + .mobile-only { + display: inline !important; + } +} + +/* Мобильная адаптация */ +@media (max-width: 768px) { + .admin-orders { + max-width: 100%; + } + + .order-details { + max-width: 100%; + } + + .info-row { + flex-direction: column; + gap: 0.5rem; + } + + .info-row strong { + width: 100%; + margin-bottom: 0.25rem; + } + + /* Адаптация таблицы заказов для мобильной версии */ + .admin-table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + display: block; + } + + .admin-table thead, + .admin-table tbody, + .admin-table tr { + display: block; + } + + .admin-table thead { + display: none; + } + + .admin-table tr { + margin-bottom: 1rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1rem; + background: var(--bg-card); + backdrop-filter: blur(10px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + + .admin-table td { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + text-align: right; + color: #ffffff; + font-weight: 400; + } + + .admin-table td:last-child { + border-bottom: none; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + .admin-table td::before { + content: attr(data-label); + font-weight: 600; + color: #ffffff; + text-align: left; + margin-right: 1rem; + } + + /* Скрываем ID в мобильной версии */ + .admin-table td.mobile-hide-id, + .admin-table td[data-label="ID"] { + display: none !important; + } + + /* Скрываем метку "Номер заказа:" в мобильной версии */ + .admin-table td[data-label="Номер заказа"]::before { + content: ''; + } +} + +.admin-table tr { + background: var(--bg-card); +} \ No newline at end of file diff --git a/client/src/pages/admin/Orders.jsx b/client/src/pages/admin/Orders.jsx index 272f4d7..557f09d 100644 --- a/client/src/pages/admin/Orders.jsx +++ b/client/src/pages/admin/Orders.jsx @@ -1,5 +1,7 @@ import { useState, useEffect } from 'react' import { adminOrdersApi } from '../../api/admin/orders' +import OrderDetailsModal from '../../components/OrderDetailsModal' +import { hasOrderEditAccess } from '../../utils/auth' import './Orders.css' function Orders() { @@ -8,16 +10,67 @@ function Orders() { const [page, setPage] = useState(1) const [pagination, setPagination] = useState(null) const [selectedOrder, setSelectedOrder] = useState(null) + const [canEditOrders, setCanEditOrders] = useState(false) + + useEffect(() => { + // Проверяем права доступа при монтировании и при изменении + const hasAccess = hasOrderEditAccess() + setCanEditOrders(hasAccess) + console.log('[admin/Orders] Проверка прав доступа:') + console.log('[admin/Orders] hasOrderEditAccess():', hasAccess) + const { getUser } = require('../../utils/auth') + console.log('[admin/Orders] getUser():', getUser()) + }, []) useEffect(() => { loadOrders() }, [page]) + // Функция для определения приоритета статуса заказа + const getStatusPriority = (order) => { + const statusSlug = order.status_slug?.toLowerCase() || '' + + // Максимальный приоритет: заказы в обработке + if (statusSlug === 'processing' || statusSlug === 'pending') { + return 1 + } + + // Минимальный приоритет: завершенные или возвращенные заказы + if (statusSlug === 'delivered' || statusSlug === 'completed' || + statusSlug === 'cancelled' || statusSlug === 'refunded') { + return 3 + } + + // Средний приоритет: остальные статусы (shipped и т.д.) + return 2 + } + + // Функция сортировки заказов по статусу + const sortOrdersByStatus = (ordersList) => { + return [...ordersList].sort((a, b) => { + const priorityA = getStatusPriority(a) + const priorityB = getStatusPriority(b) + + // Сначала сортируем по приоритету статуса + if (priorityA !== priorityB) { + return priorityA - priorityB + } + + // Если приоритет одинаковый, сортируем по дате создания (новые первыми) + const dateA = new Date(a.created_at || 0).getTime() + const dateB = new Date(b.created_at || 0).getTime() + return dateB - dateA + }) + } + const loadOrders = async () => { try { setLoading(true) const response = await adminOrdersApi.getAll({ page, limit: 20 }) - setOrders(response.data || []) + const ordersData = response.data || [] + // Сортируем заказы по статусу после получения данных + const sortedOrders = sortOrdersByStatus(ordersData) + setOrders(sortedOrders) setPagination(response.pagination) } catch (error) { console.error('Ошибка загрузки заказов:', error) @@ -72,17 +125,39 @@ function Orders() { {orders.map(order => ( - {order.id} - {order.order_number || '—'} - {order.user_id || '—'} - {formatPrice(order.total_amount || 0)} - - - {order.status_id || '—'} - + {order.id} + {order.order_number || '—'} + {order.user_id || '—'} + {formatPrice(order.total_amount || 0)} + + {order.status_name ? ( + + {order.status_name} + + ) : ( + + {order.status_id || '—'} + + )} - {formatDate(order.created_at)} - + + {order.payment_status_name ? ( + + {order.payment_status_name} + + ) : ( + — + )} + + {formatDate(order.created_at)} + @@ -112,96 +187,16 @@ function Orders() { )} - {selectedOrder && ( -
setSelectedOrder(null)}> -
e.stopPropagation()}> -

Детали заказа #{selectedOrder.id}

-
-
- Номер заказа: {selectedOrder.order_number || '—'} -
-
- Пользователь: { - selectedOrder.user - ? `${selectedOrder.user.first_name || ''} ${selectedOrder.user.last_name || ''}`.trim() || selectedOrder.user.email || `ID: ${selectedOrder.user_id}` - : selectedOrder.user_id || '—' - } - {selectedOrder.user?.email && ( - - ({selectedOrder.user.email}) - - )} -
- {selectedOrder.shipping_address && ( -
- Адрес доставки: -
-
{selectedOrder.shipping_address.first_name} {selectedOrder.shipping_address.last_name}
- {selectedOrder.shipping_address.phone && ( -
Телефон: {selectedOrder.shipping_address.phone}
- )} -
- {selectedOrder.shipping_address.country}, {selectedOrder.shipping_address.city} -
-
- {selectedOrder.shipping_address.street} - {selectedOrder.shipping_address.building && `, д. ${selectedOrder.shipping_address.building}`} - {selectedOrder.shipping_address.apartment && `, кв. ${selectedOrder.shipping_address.apartment}`} -
- {selectedOrder.shipping_address.postal_code && ( -
Индекс: {selectedOrder.shipping_address.postal_code}
- )} -
-
- )} -
- Сумма: {formatPrice(selectedOrder.total_amount || 0)} -
-
- Статус ID: {selectedOrder.status_id || '—'} -
-
- Дата создания: {formatDate(selectedOrder.created_at)} -
- {selectedOrder.items && selectedOrder.items.length > 0 && ( -
-

Товары:

- - - - - - - - - - - {selectedOrder.items.map((item, idx) => ( - - - - - - - ))} - -
ТоварКоличествоЦенаСумма
- {item.product_name || 'Товар'} - {item.product_sku && (Артикул: {item.product_sku})} -
- ID: {item.product_id} -
{item.quantity}{formatPrice(item.price || 0)}{formatPrice(item.subtotal || (item.price || 0) * item.quantity)}
-
- )} -
-
- -
-
-
- )} + setSelectedOrder(null)} + canEditOrders={canEditOrders} + onOrderUpdate={(updatedOrder) => { + setSelectedOrder(updatedOrder) + // Обновляем заказ в списке + setOrders(orders.map(o => o.id === updatedOrder.id ? updatedOrder : o)) + }} + />
) } diff --git a/client/src/pages/admin/Products.css b/client/src/pages/admin/Products.css index 23353c3..e3fd4c5 100644 --- a/client/src/pages/admin/Products.css +++ b/client/src/pages/admin/Products.css @@ -11,7 +11,24 @@ .admin-page-header h1 { margin: 0; - color: var(--text-primary); + color: #ffffff !important; +} + +.admin-products h1, +.admin-products h2, +.admin-products h3, +.admin-products h4, +.admin-products h5, +.admin-products h6 { + color: #ffffff !important; +} + +.admin-products .modal-content h2 { + color: #ffffff !important; +} + +.admin-products .admin-table th { + color: #ffffff !important; } .admin-filters { @@ -42,6 +59,17 @@ background-color: var(--bg-tertiary); } +/* В мобильной версии убираем фон таблицы, чтобы не было подложки */ +@media (max-width: 768px) { + .admin-table { + background: transparent !important; + } + + .admin-table thead { + background: transparent !important; + } +} + .admin-table th { padding: 1rem; text-align: left; @@ -56,6 +84,7 @@ color: var(--text-secondary); } + .admin-table tbody tr:hover { background-color: var(--bg-tertiary); } @@ -268,3 +297,153 @@ .pagination button:not(:disabled):hover { background-color: #f8f9fa; } + +/* Мобильная адаптация */ +@media (max-width: 768px) { + .admin-products { + max-width: 100%; + } + + .admin-page-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .admin-page-header h1 { + font-size: 1.5rem; + } + + .admin-filters { + flex-direction: column; + width: 100%; + } + + .admin-filters select { + width: 100%; + } + + .admin-table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + display: block; + background: transparent !important; + border: none !important; + box-shadow: none !important; + } + + .admin-table thead { + background: transparent !important; + } + + .admin-table thead, + .admin-table tbody, + .admin-table tr { + display: block; + } + + .admin-table thead { + display: none; + } + + .admin-table tbody { + background: transparent; + border: none; + } + + .admin-table tr { + margin-bottom: 1rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1rem; + background: var(--bg-card) !important; + backdrop-filter: blur(10px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + color: #ffffff; + } + + .admin-table td { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + text-align: right; + color: #ffffff; + font-weight: 400; + } + + .admin-table td:last-child { + border-bottom: none; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + .admin-table td::before { + content: attr(data-label); + font-weight: 600; + color: #ffffff; + text-align: left; + margin-right: 1rem; + } + + /* Стили для бейджей в мобильных карточках товаров */ + .admin-table td .badge { + color: var(--text-primary); + } + + .admin-table td .badge-success { + background-color: #d4edda; + color: #155724; + } + + .admin-table td .badge-danger { + background-color: #f8d7da; + color: #721c24; + } + + /* Скрываем ID в мобильной версии */ + .admin-table td.mobile-hide-id, + .admin-table td[data-label="ID"] { + display: none !important; + } + + .modal-content { + max-width: 95vw; + width: 95vw; + padding: 1rem; + margin: 0.5rem; + } + + .modal-content h2 { + font-size: 1.25rem; + } + + .form-group { + margin-bottom: 1rem; + } + + .form-actions { + flex-direction: column; + width: 100%; + } + + .form-actions button { + width: 100%; + } + + .pagination { + flex-wrap: wrap; + gap: 0.5rem; + } + + .pagination button { + flex: 1; + min-width: 80px; + } +} + +.admin-table tr { + background: var(--bg-card); +} diff --git a/client/src/pages/admin/Products.jsx b/client/src/pages/admin/Products.jsx index e781318..d160686 100644 --- a/client/src/pages/admin/Products.jsx +++ b/client/src/pages/admin/Products.jsx @@ -16,6 +16,8 @@ function Products() { const [editingProduct, setEditingProduct] = useState(null) const [productImages, setProductImages] = useState([]) const [uploadingImages, setUploadingImages] = useState(false) + const [slugManuallyEdited, setSlugManuallyEdited] = useState(false) + const [comparePriceManuallyEdited, setComparePriceManuallyEdited] = useState(false) const [formData, setFormData] = useState({ name: '', slug: '', @@ -265,6 +267,8 @@ function Products() { // Сначала открываем модальное окно, чтобы пользователь видел, что что-то происходит setShowModal(true) setEditingProduct(product) + setSlugManuallyEdited(false) + setComparePriceManuallyEdited(false) setFormData({ name: product.name || '', slug: product.slug || '', @@ -302,8 +306,38 @@ function Products() { } } + const transliterate = (text) => { + const cyrillicToLatin = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', + 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sch', + 'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + } + + return text.split('').map(char => cyrillicToLatin[char] || char).join('') + } + + const generateSlug = (name) => { + if (!name) return '' + return transliterate(name) + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Удаляем специальные символы + .replace(/[\s_]+/g, '-') // Заменяем пробелы и подчеркивания на дефисы + .replace(/-+/g, '-') // Удаляем множественные дефисы + .replace(/^-+|-+$/g, '') // Удаляем дефисы в начале и конце + } + const handleCreate = () => { setEditingProduct(null) + setSlugManuallyEdited(false) + setComparePriceManuallyEdited(false) setFormData({ name: '', slug: '', @@ -462,20 +496,20 @@ function Products() { if (!product) return null return ( - {product.id ?? '—'} - + {product.id ?? '—'} + {product.name ?? '—'} {import.meta.env.DEV && !product.name && ( (нет name) )} - + {product.sku ?? '—'} {import.meta.env.DEV && !product.sku && ( (нет sku) )} - + {product.price !== null && product.price !== undefined ? `${Number(product.price).toFixed(2)} ₽` : '—'} @@ -483,13 +517,13 @@ function Products() { (нет price) )} - {product.stock_quantity ?? 0} - + {product.stock_quantity ?? 0} + {product.is_active ? 'Активен' : 'Неактивен'} - +
-
+
setFormData({ ...formData, slug: e.target.value })} + onChange={(e) => { + setSlugManuallyEdited(true) + setFormData({ ...formData, slug: e.target.value }) + }} required />
@@ -584,7 +628,14 @@ function Products() { type="number" step="0.01" value={formData.price} - onChange={(e) => setFormData({ ...formData, price: e.target.value })} + onChange={(e) => { + const newPrice = e.target.value + setFormData({ + ...formData, + price: newPrice, + compare_at_price: comparePriceManuallyEdited ? formData.compare_at_price : (newPrice || formData.compare_at_price) + }) + }} required />
@@ -626,13 +677,16 @@ function Products() { placeholder="Полное описание товара" />
-
+
setFormData({ ...formData, compare_at_price: e.target.value })} + onChange={(e) => { + setComparePriceManuallyEdited(true) + setFormData({ ...formData, compare_at_price: e.target.value }) + }} placeholder="Цена до скидки" />
@@ -711,22 +765,93 @@ function Products() { {editingProduct?.id && (
-
+
+
@@ -771,24 +896,54 @@ function Products() { Основное )} -
+
{!image.is_primary && ( )}
diff --git a/client/src/pages/admin/Reviews.css b/client/src/pages/admin/Reviews.css index 8c6dcc8..7295b2e 100644 --- a/client/src/pages/admin/Reviews.css +++ b/client/src/pages/admin/Reviews.css @@ -174,3 +174,61 @@ padding: 4rem 2rem; color: #999; } + +/* Мобильная адаптация */ +@media (max-width: 768px) { + .admin-reviews { + padding: 1rem; + } + + .admin-page-header h1 { + font-size: 1.5rem; + } + + .reviews-filters { + flex-direction: column; + gap: 0.5rem; + } + + .filter-btn { + width: 100%; + padding: 0.75rem 1rem; + font-size: 0.9rem; + } + + .review-card { + padding: 1rem; + } + + .review-card-header { + flex-direction: column; + align-items: flex-start; + } + + .review-info { + width: 100%; + } + + .review-product { + font-size: 1rem; + } + + .review-actions { + flex-direction: column; + width: 100%; + } + + .review-actions button { + width: 100%; + } + + .pagination { + flex-wrap: wrap; + gap: 0.5rem; + } + + .pagination button { + flex: 1; + min-width: 80px; + } +} diff --git a/client/src/pages/admin/Roles.css b/client/src/pages/admin/Roles.css index f8f9fae..e560680 100644 --- a/client/src/pages/admin/Roles.css +++ b/client/src/pages/admin/Roles.css @@ -180,6 +180,129 @@ margin: 1.5rem 0; } +/* Дерево прав */ +.permissions-tree-container { + max-height: 60vh; + overflow-y: auto; + margin: 1.5rem 0; +} + +.permissions-tree { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.permissions-tree-resource { + border: 1px solid var(--border-color); + border-radius: 6px; + background: var(--bg-card); + overflow: hidden; +} + +.permissions-tree-resource-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + background: var(--bg-tertiary); + cursor: pointer; + transition: background-color 0.2s; + user-select: none; +} + +.permissions-tree-resource-header:hover { + background: var(--bg-secondary); +} + +.permissions-tree-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + font-size: 0.75rem; + color: var(--text-secondary); + flex-shrink: 0; +} + +.permissions-tree-resource-name { + font-weight: 600; + color: var(--text-primary); + font-size: 1rem; + flex: 1; +} + +.permissions-tree-resource-count { + color: var(--text-secondary); + font-size: 0.875rem; +} + +.permissions-tree-permissions { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + background: var(--bg-color); +} + +.permissions-tree-permission { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 1rem; + padding: 0.75rem; + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 4px; + transition: all 0.2s; +} + +.permissions-tree-permission:hover { + background: var(--bg-tertiary); + border-color: var(--border-light); +} + +.permissions-tree-permission-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.permissions-tree-permission-name { + font-weight: 500; + color: var(--text-primary); + font-size: 0.95rem; +} + +.permissions-tree-permission-description { + font-size: 0.85rem; + color: var(--text-secondary); + line-height: 1.4; +} + +.permissions-tree-permission-slug { + font-family: 'Courier New', monospace; + font-size: 0.75rem; + color: var(--text-muted); + background: var(--bg-color); + padding: 0.25rem 0.5rem; + border-radius: 4px; + border: 1px solid var(--border-color); + display: inline-block; + max-width: fit-content; +} + +.permissions-tree-permission.assigned { + opacity: 0.7; + background: var(--bg-tertiary); +} + +.permissions-tree-permission.assigned:hover { + background: var(--bg-secondary); +} + /* Таблица прав */ .permissions-table { width: 100%; @@ -308,3 +431,187 @@ .empty-state p { margin-bottom: 1rem; } + +/* Мобильная адаптация */ +@media (max-width: 768px) { + .admin-roles .admin-table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + display: block; + background: transparent !important; + border: none !important; + box-shadow: none !important; + } + + .admin-roles .admin-table thead, + .admin-roles .admin-table tbody, + .admin-roles .admin-table tr { + display: block; + } + + .admin-roles .admin-table thead { + display: none; + } + + .admin-roles .admin-table tbody { + background: transparent; + border: none; + } + + .admin-roles .admin-table tr { + margin-bottom: 1rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1rem; + background: var(--bg-card); + backdrop-filter: blur(10px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + color: #ffffff; + } + + .admin-roles .admin-table td { + display: flex; + justify-content: flex-start; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + text-align: left; + color: #ffffff; + font-weight: 400; + } + + .admin-roles .admin-table td[data-label="Название"], + .admin-roles .admin-table td[data-label="Описание"] { + flex-direction: column; + align-items: flex-start; + } + + .admin-roles .admin-table td[data-label="Название"]::before, + .admin-roles .admin-table td[data-label="Описание"]::before { + content: ''; + display: none; + } + + .admin-roles .admin-table td:last-child { + border-bottom: none; + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + } + + .admin-roles .admin-table td:last-child .btn { + width: 100%; + padding: 0.625rem 1rem; + font-size: 0.875rem; + text-align: center; + margin: 0; + } + + .admin-roles .admin-table td::before { + content: attr(data-label); + font-weight: 600; + color: #ffffff; + text-align: left; + margin-right: 1rem; + margin-bottom: 0.25rem; + } + + .permissions-modal { + max-width: 95vw; + max-height: 95vh; + margin: 0.5rem; + } + + .add-permission-modal { + max-width: 95vw; + max-height: 90vh; + margin: 0.5rem; + } + + .permissions-modal-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .permissions-modal-header h2 { + font-size: 1.25rem; + } + + .permissions-table-container, + .permissions-tree-container { + max-height: 50vh; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .permissions-table { + min-width: 600px; + font-size: 0.85rem; + } + + .permissions-table th, + .permissions-table td { + padding: 0.5rem; + } + + .permissions-tree-resource-header { + padding: 0.625rem 0.75rem; + } + + .permissions-tree-resource-name { + font-size: 0.9rem; + } + + .permissions-tree-permission { + flex-direction: column; + align-items: stretch; + gap: 0.75rem; + padding: 0.625rem; + } + + .permissions-tree-permission-info { + width: 100%; + } + + .permissions-tree-permission button { + width: 100%; + } + + .permission-group-title { + font-size: 1rem; + } + + .permission-group-description { + font-size: 0.8rem; + } + + .available-permissions-list { + max-height: 60vh; + } + + .available-permission-item { + flex-direction: column; + align-items: flex-start; + gap: 0.75rem; + padding: 0.75rem; + } + + .permission-details { + width: 100%; + } + + .permission-name-row { + flex-direction: column; + align-items: flex-start; + gap: 0.5rem; + } + + .permission-name-row strong { + font-size: 0.9rem; + } + + .permission-resource { + font-size: 0.8rem; + } +} diff --git a/client/src/pages/admin/Roles.jsx b/client/src/pages/admin/Roles.jsx index c966e11..4f6c949 100644 --- a/client/src/pages/admin/Roles.jsx +++ b/client/src/pages/admin/Roles.jsx @@ -15,10 +15,14 @@ function Roles() { const [loadingPermissions, setLoadingPermissions] = useState(false) const [showAddPermissionModal, setShowAddPermissionModal] = useState(false) const [availablePermissions, setAvailablePermissions] = useState([]) // Права, которые можно добавить + const [expandedResources, setExpandedResources] = useState({}) // Состояние раскрытых ресурсов в дереве + const [expandedResourcesAdd, setExpandedResourcesAdd] = useState({}) // Состояние раскрытых ресурсов в модальном окне добавления const [formData, setFormData] = useState({ name: '', + slug: '', description: '' }) + const [slugManuallyEdited, setSlugManuallyEdited] = useState(false) useEffect(() => { loadRoles() @@ -120,6 +124,19 @@ function Roles() { setRolePermissions(permissionIds) setRolePermissionsFull(permissionsData) + // Инициализируем все ресурсы как свернутые (по умолчанию) + // Используем split по slug для определения ресурса + const resources = permissionsData.reduce((acc, permission) => { + const slug = permission.slug || '' + const parts = slug.split('.') + const resourceSlug = parts[0] || 'other' + if (!acc[resourceSlug]) { + acc[resourceSlug] = false // По умолчанию свернуто + } + return acc + }, {}) + setExpandedResources(resources) + // Показываем ВСЕ права из БД, а не только не назначенные // Пользователь должен видеть все доступные права const available = permissions.filter(p => { @@ -132,6 +149,7 @@ function Roles() { console.error('Ошибка загрузки прав роли:', error) setRolePermissions([]) setRolePermissionsFull([]) + setExpandedResources({}) // Если пермишены загружены, используем их, иначе пустой массив if (permissions.length > 0) { setAvailablePermissions(permissions) @@ -154,10 +172,40 @@ function Roles() { } } + const transliterate = (text) => { + const cyrillicToLatin = { + 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', + 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', + 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', + 'ф': 'f', 'х': 'h', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', + 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya', + 'А': 'A', 'Б': 'B', 'В': 'V', 'Г': 'G', 'Д': 'D', 'Е': 'E', 'Ё': 'Yo', + 'Ж': 'Zh', 'З': 'Z', 'И': 'I', 'Й': 'Y', 'К': 'K', 'Л': 'L', 'М': 'M', + 'Н': 'N', 'О': 'O', 'П': 'P', 'Р': 'R', 'С': 'S', 'Т': 'T', 'У': 'U', + 'Ф': 'F', 'Х': 'H', 'Ц': 'Ts', 'Ч': 'Ch', 'Ш': 'Sh', 'Щ': 'Sch', + 'Ъ': '', 'Ы': 'Y', 'Ь': '', 'Э': 'E', 'Ю': 'Yu', 'Я': 'Ya' + } + + return text.split('').map(char => cyrillicToLatin[char] || char).join('') + } + + const generateSlug = (name) => { + if (!name) return '' + return transliterate(name) + .toLowerCase() + .trim() + .replace(/[^\w\s-]/g, '') // Удаляем специальные символы + .replace(/[\s_]+/g, '-') // Заменяем пробелы и подчеркивания на дефисы + .replace(/-+/g, '-') // Удаляем множественные дефисы + .replace(/^-+|-+$/g, '') // Удаляем дефисы в начале и конце + } + const handleEdit = (role) => { setEditingRole(role) + setSlugManuallyEdited(false) setFormData({ name: role.name || '', + slug: role.slug || '', description: role.description || '' }) setShowModal(true) @@ -165,8 +213,10 @@ function Roles() { const handleCreate = () => { setEditingRole(null) + setSlugManuallyEdited(false) setFormData({ name: '', + slug: '', description: '' }) setShowModal(true) @@ -285,16 +335,32 @@ function Roles() { setAvailablePermissions([]) } - // Группируем права роли по ресурсам для отображения в таблице + // Группируем права роли по ресурсам для отображения в дереве + // Используем split по slug (формат: "resource.action") const groupedRolePermissions = rolePermissionsFull.reduce((acc, permission) => { - const resourceName = permission.resource?.name || permission.resource_name || 'Другое' - if (!acc[resourceName]) { - acc[resourceName] = [] + const slug = permission.slug || '' + const parts = slug.split('.') + const resourceSlug = parts[0] || 'other' + const resourceName = permission.resource?.name || permission.resource_name || resourceSlug.charAt(0).toUpperCase() + resourceSlug.slice(1) + + if (!acc[resourceSlug]) { + acc[resourceSlug] = { + name: resourceName, + permissions: [] + } } - acc[resourceName].push(permission) + acc[resourceSlug].permissions.push(permission) return acc }, {}) + // Функция для переключения раскрытия ресурса + const toggleResource = (resourceSlug) => { + setExpandedResources(prev => ({ + ...prev, + [resourceSlug]: !prev[resourceSlug] + })) + } + return (
@@ -310,7 +376,6 @@ function Roles() { - @@ -319,24 +384,21 @@ function Roles() { {roles.map(role => ( - - - - + + + @@ -141,20 +145,53 @@ function Users() { {users.map(user => ( - - - - - - - - + + + + + + + + ))} @@ -187,12 +224,11 @@ function Users() {

{editingUser ? 'Редактировать пользователя' : 'Создать пользователя'}

- + setFormData({ ...formData, email: e.target.value })} - required />
@@ -228,6 +264,15 @@ function Users() { placeholder="Введите Telegram ID" />
+
+ + setFormData({ ...formData, telegram_user: e.target.value.replace('@', '') })} + placeholder="Введите Telegram username (без @)" + /> +
ID Название Описание Действия
{role.id}{role.name}{role.description || '—'} + {role.name}{role.description || '—'} @@ -357,7 +419,26 @@ function Roles() { setFormData({ ...formData, name: e.target.value })} + onChange={(e) => { + const newName = e.target.value + setFormData({ + ...formData, + name: newName, + slug: slugManuallyEdited ? formData.slug : generateSlug(newName) + }) + }} + required + /> + +
+ + { + setSlugManuallyEdited(true) + setFormData({ ...formData, slug: e.target.value }) + }} required />
@@ -389,19 +470,23 @@ function Roles() {

Управление правами: {editingRole.name}

+ + ))} + + )} + + ) + })} + ) : (

Нет назначенных прав

@@ -469,6 +562,17 @@ function Roles() { // Показываем ВСЕ права из БД const available = permissions.filter(p => p && p.id) setAvailablePermissions(available) + // Инициализируем все ресурсы как свернутые для дерева + const resources = permissions.reduce((acc, permission) => { + const slug = permission.slug || '' + const parts = slug.split('.') + const resourceSlug = parts[0] || 'other' + if (!acc[resourceSlug]) { + acc[resourceSlug] = false // По умолчанию свернуто + } + return acc + }, {}) + setExpandedResourcesAdd(resources) setShowAddPermissionModal(true) }} disabled={loadingPermissions} @@ -516,36 +620,96 @@ function Roles() {
) : permissions.length > 0 ? ( -
- {permissions.map(permission => { - const isAssigned = rolePermissions.includes(permission.id) - return ( -
-
-
- {permission.name} - {isAssigned && Уже добавлено} - {permission.slug} -
- {permission.description && ( -
{permission.description}
- )} -
- Ресурс: {permission.resource?.name || permission.resource_name || '—'} -
-
- + (() => { + // Группируем права по ресурсам для дерева + const groupedAvailablePermissions = permissions.reduce((acc, permission) => { + const slug = permission.slug || '' + const parts = slug.split('.') + const resourceSlug = parts[0] || 'other' + const resourceName = permission.resource?.name || permission.resource_name || resourceSlug.charAt(0).toUpperCase() + resourceSlug.slice(1) + + if (!acc[resourceSlug]) { + acc[resourceSlug] = { + name: resourceName, + permissions: [] + } + } + acc[resourceSlug].permissions.push(permission) + return acc + }, {}) + + // Инициализируем все ресурсы как свернутые при первом открытии + if (Object.keys(expandedResourcesAdd).length === 0) { + const resources = Object.keys(groupedAvailablePermissions).reduce((acc, resourceSlug) => { + acc[resourceSlug] = false // По умолчанию свернуто + return acc + }, {}) + setExpandedResourcesAdd(resources) + } + + return ( +
+
+ {Object.keys(groupedAvailablePermissions).map((resourceSlug) => { + const resource = groupedAvailablePermissions[resourceSlug] + const isExpanded = expandedResourcesAdd[resourceSlug] === true + return ( +
+
setExpandedResourcesAdd(prev => ({ + ...prev, + [resourceSlug]: !prev[resourceSlug] + }))} + > + + {isExpanded ? '▼' : '▶'} + + {resource.name} + + ({resource.permissions.length}) + +
+ {isExpanded && ( +
+ {resource.permissions.map((permission) => { + const isAssigned = rolePermissions.includes(permission.id) + return ( +
+
+
+ {permission.name} + {isAssigned && Уже добавлено} +
+ {permission.description && ( +
+ {permission.description} +
+ )} + + {permission.slug} + +
+ +
+ ) + })} +
+ )} +
+ ) + })}
- ) - })} -
+
+ ) + })() ) : (
Нет доступных прав для добавления
)} @@ -554,7 +718,10 @@ function Roles() { diff --git a/client/src/pages/admin/Settings.jsx b/client/src/pages/admin/Settings.jsx index 5b6efb6..40ab35a 100644 --- a/client/src/pages/admin/Settings.jsx +++ b/client/src/pages/admin/Settings.jsx @@ -147,10 +147,20 @@ function Settings() { )}
- {settings.map(setting => ( + {settings.map(setting => { + // Улучшаем отображение названий настроек + const getSettingDisplayName = (key) => { + const displayNames = { + 'ratings_enabled': 'Отзывы и рейтинги', + 'reviews_enabled': 'Отзывы и рейтинги' + } + return displayNames[key] || key + } + + return (
-

{setting.key}

+

{getSettingDisplayName(setting.key)}

{setting.description && (

{setting.description}

)} @@ -186,7 +196,8 @@ function Settings() { )}
- ))} + ) + })} {settings.length === 0 && (
diff --git a/client/src/pages/admin/Users.css b/client/src/pages/admin/Users.css index 5e5b99b..1d71541 100644 --- a/client/src/pages/admin/Users.css +++ b/client/src/pages/admin/Users.css @@ -1,3 +1,312 @@ .admin-users { max-width: 1400px; } + +/* Используем общие стили из Products.css для таблиц и модальных окон */ + +.admin-users .admin-page-header h1 { + color: #ffffff !important; +} + +.admin-users h1, +.admin-users h2, +.admin-users h3, +.admin-users h4, +.admin-users h5, +.admin-users h6 { + color: #ffffff !important; +} + +.admin-users .modal-content h2 { + color: #ffffff !important; +} + +.admin-users .admin-table th { + color: #ffffff !important; +} + +/* Стили для кнопок */ +.btn { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + transition: all 0.2s; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 100px; +} + +.btn-primary { + background-color: #3498db; + color: white; +} + +.btn-primary:hover { + background-color: #2980b9; +} + +.btn-secondary { + background-color: #95a5a6; + color: white; +} + +.btn-secondary:hover { + background-color: #7f8c8d; +} + +.btn-danger { + background-color: #e74c3c; + color: white; +} + +.btn-danger:hover { + background-color: #c0392b; +} + +.btn-sm { + padding: 0.5rem 0.75rem; + font-size: 0.75rem; +} + +.form-actions { + display: flex; + gap: 1rem; + justify-content: flex-end; + align-items: stretch; + margin-top: 1.5rem; + width: 100%; +} + +.form-actions button, +.form-actions .btn { + min-width: 120px; + padding: 0.75rem 1.5rem; + font-size: 0.875rem; + height: 42px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + margin: 0; + border: none; + border-radius: 4px; + cursor: pointer; + font-weight: 500; + transition: all 0.2s; + flex-shrink: 0; +} + +/* Обеспечиваем одинаковый размер кнопок в полной версии */ +@media (min-width: 769px) { + .form-actions { + align-items: stretch; + } + + .form-actions button, + .form-actions .btn, + .form-actions .btn-primary, + .form-actions .btn-secondary { + min-width: 120px; + padding: 0.75rem 1.5rem; + font-size: 0.875rem; + height: 42px; + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + margin: 0; + } +} + +/* Кнопки действий с иконками */ +.action-buttons { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.btn-icon { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.5rem; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + background: transparent; + color: var(--text-secondary); + min-width: 36px; + height: 36px; +} + +.btn-icon:hover { + background: var(--bg-tertiary); + color: var(--text-primary); +} + +.btn-icon.btn-edit:hover { + background: rgba(52, 152, 219, 0.1); + color: #3498db; +} + +.btn-icon.btn-delete:hover { + background: rgba(231, 76, 60, 0.1); + color: #e74c3c; +} + +.btn-icon svg { + flex-shrink: 0; +} + +.action-text { + font-size: 0.875rem; +} + +/* В полной версии показываем только иконки, скрываем мобильные кнопки */ +@media (min-width: 769px) { + .mobile-only { + display: none !important; + } + + .desktop-only { + display: inline-flex; + } + + .btn-icon { + padding: 0.5rem; + min-width: 36px; + } +} + +/* Мобильная адаптация */ +@media (max-width: 768px) { + .admin-users { + max-width: 100%; + } + + .admin-page-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .admin-page-header h1 { + font-size: 1.5rem; + } + + .admin-page-header button { + width: 100%; + } + + .admin-table { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + display: block; + } + + .admin-table thead, + .admin-table tbody, + .admin-table tr { + display: block; + } + + .admin-table thead { + display: none; + } + + .admin-table tr { + margin-bottom: 1rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 8px; + padding: 1rem; + background: rgba(255, 255, 255, 0.12); + backdrop-filter: blur(10px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); + } + + .admin-table td { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.15); + text-align: right; + color: #ffffff; + font-weight: 400; + } + + .admin-table td:last-child { + border-bottom: none; + flex-direction: column; + align-items: stretch; + gap: 0.5rem; + } + + /* В мобильной версии показываем текстовые кнопки, скрываем иконки */ + .desktop-only { + display: none !important; + } + + .mobile-only { + display: inline-block; + } + + .action-buttons { + flex-direction: column; + gap: 0.5rem; + width: 100%; + } + + .action-buttons .btn { + width: 100%; + padding: 0.75rem; + font-size: 0.875rem; + } + + .admin-table td::before { + content: attr(data-label); + font-weight: 600; + color: #ffffff; + text-align: left; + margin-right: 1rem; + } + + .admin-table td:last-child::before { + content: 'Действия'; + margin-bottom: 0.5rem; + } + + /* Скрываем ID в мобильной версии */ + .admin-table td.mobile-hide-id, + .admin-table td[data-label="ID"] { + display: none !important; + } + + /* Скрываем пустые ячейки в мобильной версии */ + .admin-table td:empty { + display: none; + } + + /* Скрываем ячейки с классом hide-empty-mobile в мобильной версии (ячейки с "—") */ + .admin-table td.hide-empty-mobile { + display: none; + } + + .pagination { + flex-wrap: wrap; + gap: 0.5rem; + } + + .pagination button { + flex: 1; + min-width: 80px; + } +} diff --git a/client/src/pages/admin/Users.jsx b/client/src/pages/admin/Users.jsx index bd0c82d..17b1a09 100644 --- a/client/src/pages/admin/Users.jsx +++ b/client/src/pages/admin/Users.jsx @@ -17,6 +17,7 @@ function Users() { last_name: '', phone: '', telegram_id: '', + telegram_user: '', role_id: null }) @@ -66,6 +67,7 @@ function Users() { last_name: user.last_name || '', phone: user.phone || '', telegram_id: user.telegram_id || '', + telegram_user: user.telegram_user || '', role_id: user.role_id || null }) setShowModal(true) @@ -79,6 +81,7 @@ function Users() { last_name: '', phone: '', telegram_id: '', + telegram_user: '', role_id: null }) setShowModal(true) @@ -134,6 +137,7 @@ function Users() {
Фамилия Телефон Telegram IDTelegram User Роль Действия
{user.id}{user.email || '—'}{user.first_name || '—'}{user.last_name || '—'}{user.phone || '—'}{user.telegram_id || '—'}{getRoleName(user.role_id)} - - + {user.id}{user.email || '—'}{user.first_name || '—'}{user.last_name || '—'}{user.phone || '—'}{user.telegram_id || '—'}{user.telegram_user ? `@${user.telegram_user}` : '—'}{getRoleName(user.role_id)} +
+ {/* Иконки для десктопной версии */} + + + {/* Текстовые кнопки для мобильной версии */} + + +