mobile ui
This commit is contained in:
parent
175f2079a6
commit
e793ef1de6
111
frontend/README.md
Normal file
111
frontend/README.md
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# CoreSync MRM Frontend
|
||||||
|
|
||||||
|
Адаптивное веб-приложение для управления email-рассылками.
|
||||||
|
|
||||||
|
## Адаптивность
|
||||||
|
|
||||||
|
Приложение полностью адаптировано для работы на всех устройствах:
|
||||||
|
|
||||||
|
### Поддерживаемые разрешения:
|
||||||
|
- **Desktop**: 1200px+
|
||||||
|
- **Tablet**: 768px - 1199px
|
||||||
|
- **Mobile**: 320px - 767px
|
||||||
|
- **Small Mobile**: до 479px
|
||||||
|
|
||||||
|
### Ключевые особенности адаптивности:
|
||||||
|
|
||||||
|
#### 1. Мобильное меню
|
||||||
|
- На мобильных устройствах боковое меню скрывается и открывается по кнопке
|
||||||
|
- Добавлен оверлей для закрытия меню
|
||||||
|
- Плавные анимации открытия/закрытия
|
||||||
|
|
||||||
|
#### 2. Адаптивные таблицы
|
||||||
|
- Горизонтальная прокрутка на мобильных устройствах
|
||||||
|
- Оптимизированные размеры ячеек для touch-устройств
|
||||||
|
- Улучшенная читаемость на маленьких экранах
|
||||||
|
|
||||||
|
#### 3. Адаптивные формы
|
||||||
|
- Увеличенные размеры полей ввода для touch-устройств
|
||||||
|
- Минимальная высота 44px для всех интерактивных элементов
|
||||||
|
- Оптимизированные отступы и размеры шрифтов
|
||||||
|
|
||||||
|
#### 4. Адаптивные кнопки
|
||||||
|
- Увеличенные размеры для touch-устройств
|
||||||
|
- Улучшенная доступность
|
||||||
|
- Поддержка hover и active состояний
|
||||||
|
|
||||||
|
#### 5. Модальные окна
|
||||||
|
- Адаптивные размеры и отступы
|
||||||
|
- Оптимизированное позиционирование на мобильных
|
||||||
|
- Поддержка прокрутки контента
|
||||||
|
|
||||||
|
### Технические особенности:
|
||||||
|
|
||||||
|
#### CSS Media Queries:
|
||||||
|
```css
|
||||||
|
/* Планшеты */
|
||||||
|
@media (max-width: 1024px) { ... }
|
||||||
|
|
||||||
|
/* Мобильные устройства */
|
||||||
|
@media (max-width: 768px) { ... }
|
||||||
|
|
||||||
|
/* Маленькие мобильные */
|
||||||
|
@media (max-width: 480px) { ... }
|
||||||
|
|
||||||
|
/* Touch устройства */
|
||||||
|
@media (hover: none) and (pointer: coarse) { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Поддержка устройств:
|
||||||
|
- **iOS Safari**: Полная поддержка
|
||||||
|
- **Android Chrome**: Полная поддержка
|
||||||
|
- **Desktop browsers**: Chrome, Firefox, Safari, Edge
|
||||||
|
- **Touch devices**: iPad, iPhone, Android tablets/phones
|
||||||
|
|
||||||
|
### Структура стилей:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── styles/
|
||||||
|
│ ├── Common.module.css # Общие адаптивные стили
|
||||||
|
│ ├── Modal.module.css # Адаптивные модальные окна
|
||||||
|
│ ├── MultiSelect.module.css # Адаптивные селекты
|
||||||
|
│ └── Paginator.module.css # Адаптивная пагинация
|
||||||
|
├── components/
|
||||||
|
│ ├── Header.module.css # Адаптивный хедер
|
||||||
|
│ └── SideMenu.module.css # Адаптивное меню
|
||||||
|
└── pages/
|
||||||
|
├── Dashboard.module.css # Адаптивная главная страница
|
||||||
|
└── Login.module.css # Адаптивная страница входа
|
||||||
|
```
|
||||||
|
|
||||||
|
### Производительность:
|
||||||
|
|
||||||
|
- Оптимизированные изображения и иконки
|
||||||
|
- Минимальные CSS-анимации для плавности
|
||||||
|
- Эффективная работа с touch-событиями
|
||||||
|
- Поддержка `-webkit-overflow-scrolling: touch`
|
||||||
|
|
||||||
|
### Доступность:
|
||||||
|
|
||||||
|
- Поддержка screen readers
|
||||||
|
- Правильная семантика HTML
|
||||||
|
- Достаточный контраст цветов
|
||||||
|
- Увеличенные размеры для touch-интерфейса
|
||||||
|
|
||||||
|
## Запуск проекта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Приложение будет доступно по адресу `http://localhost:3000`
|
||||||
|
|
||||||
|
## Сборка для продакшена
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Собранные файлы будут в папке `build/`
|
||||||
@ -10,7 +10,7 @@
|
|||||||
"react-scripts": "5.0.1",
|
"react-scripts": "5.0.1",
|
||||||
"react-select": "^5.10.2"
|
"react-select": "^5.10.2"
|
||||||
},
|
},
|
||||||
"proxy": "http://project.ru",
|
"proxy": "http://project.ru:8081",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "react-scripts start",
|
"start": "react-scripts start",
|
||||||
"build": "react-scripts build",
|
"build": "react-scripts build",
|
||||||
|
|||||||
@ -1,17 +1,19 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%PUBLIC_URL%/logo.ico" />
|
<link rel="icon" href="%PUBLIC_URL%/logo.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
|
||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#6366f1" />
|
||||||
<meta name="description" content="Web site created using create-react-app" />
|
<meta name="description" content="CoreSync MRM - Система управления email-рассылками" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.png" />
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo.png" />
|
||||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
<title>React App</title>
|
<title>CoreSync MRM</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>Для работы приложения необходимо включить JavaScript</noscript>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -12,16 +12,19 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.right {
|
.right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user {
|
.user {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout {
|
.logout {
|
||||||
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@ -33,6 +36,59 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logout:hover {
|
.logout:hover {
|
||||||
background: linear-gradient(90deg, #3730a3 0%, #06b6d4 100%);
|
background: linear-gradient(90deg, #3730a3 0%, #06b6d4 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.header {
|
||||||
|
height: 48px;
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.right {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch устройств */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.logout {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,26 +1,59 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import styles from './SideMenu.module.css';
|
import styles from './SideMenu.module.css';
|
||||||
|
|
||||||
const SideMenu = ({ active, onSelect }) => {
|
const SideMenu = ({ active, onSelect }) => {
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleMenuSelect = (menuItem) => {
|
||||||
|
onSelect(menuItem);
|
||||||
|
setIsMobileMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMobileMenu = () => {
|
||||||
|
setIsMobileMenuOpen(!isMobileMenuOpen);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={styles.menu}>
|
<>
|
||||||
<div className={styles.project}>CoreSync MRM</div>
|
{/* Мобильная кнопка меню */}
|
||||||
<nav className={styles.nav}>
|
<button
|
||||||
<div className={styles.section}>Email-рассылки</div>
|
className={styles.mobileMenuButton}
|
||||||
<ul>
|
onClick={toggleMobileMenu}
|
||||||
<li className={active === 'smtp' ? styles.active : ''} onClick={() => onSelect('smtp')}>SMTP-сервера</li>
|
aria-label="Открыть меню"
|
||||||
<li className={active === 'template' ? styles.active : ''} onClick={() => onSelect('template')}>Шаблон письма</li>
|
>
|
||||||
<li className={active === 'unsubscribed' ? styles.active : ''} onClick={() => onSelect('unsubscribed')}>Отписались</li>
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<li className={active === 'groups' ? styles.active : ''} onClick={() => onSelect('groups')}>Подписчики и группы</li>
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
||||||
<li className={active === 'history' ? styles.active : ''} onClick={() => onSelect('history')}>История отправок</li>
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
||||||
<li className={active === 'campaign' ? styles.active : ''} onClick={() => onSelect('campaign')}>Кампания</li>
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
||||||
</ul>
|
</svg>
|
||||||
<div className={styles.section}>Администрирование</div>
|
</button>
|
||||||
<ul>
|
|
||||||
<li className={active === 'users' ? styles.active : ''} onClick={() => onSelect('users')}>Управление пользователями</li>
|
{/* Оверлей для мобильного меню */}
|
||||||
</ul>
|
<div
|
||||||
</nav>
|
className={`${styles.mobileOverlay} ${isMobileMenuOpen ? styles.open : ''}`}
|
||||||
</aside>
|
onClick={() => setIsMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Боковое меню */}
|
||||||
|
<aside className={`${styles.menu} ${isMobileMenuOpen ? styles.open : ''}`}>
|
||||||
|
<div className={styles.project}>CoreSync MRM</div>
|
||||||
|
<nav className={styles.nav}>
|
||||||
|
<div className={styles.section}>Email-рассылки</div>
|
||||||
|
<ul>
|
||||||
|
<li className={active === 'smtp' ? styles.active : ''} onClick={() => handleMenuSelect('smtp')}>SMTP-сервера</li>
|
||||||
|
<li className={active === 'template' ? styles.active : ''} onClick={() => handleMenuSelect('template')}>Шаблон письма</li>
|
||||||
|
<li className={active === 'unsubscribed' ? styles.active : ''} onClick={() => handleMenuSelect('unsubscribed')}>Отписались</li>
|
||||||
|
<li className={active === 'groups' ? styles.active : ''} onClick={() => handleMenuSelect('groups')}>Подписчики и группы</li>
|
||||||
|
<li className={active === 'history' ? styles.active : ''} onClick={() => handleMenuSelect('history')}>История отправок</li>
|
||||||
|
<li className={active === 'campaign' ? styles.active : ''} onClick={() => handleMenuSelect('campaign')}>Кампания</li>
|
||||||
|
</ul>
|
||||||
|
<div className={styles.section}>Администрирование</div>
|
||||||
|
<ul>
|
||||||
|
<li className={active === 'users' ? styles.active : ''} onClick={() => handleMenuSelect('users')}>Управление пользователями</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,9 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 0 0 24px 0;
|
padding: 0 0 24px 0;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project {
|
.project {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
@ -16,10 +18,12 @@
|
|||||||
border-bottom: 1px solid #e5e7eb;
|
border-bottom: 1px solid #e5e7eb;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 0 0 0 0;
|
padding: 0 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.section {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
@ -28,11 +32,13 @@
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
ul {
|
ul {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0 0 0 0;
|
padding: 0 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@ -41,13 +47,148 @@ li {
|
|||||||
border-left: 3px solid transparent;
|
border-left: 3px solid transparent;
|
||||||
transition: background 0.15s, border 0.15s, color 0.15s;
|
transition: background 0.15s, border 0.15s, color 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
li:hover {
|
li:hover {
|
||||||
background: #e0e7ff;
|
background: #e0e7ff;
|
||||||
color: #3730a3;
|
color: #3730a3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active {
|
.active {
|
||||||
background: #e0e7ff;
|
background: #e0e7ff;
|
||||||
color: #3730a3;
|
color: #3730a3;
|
||||||
border-left: 3px solid #6366f1;
|
border-left: 3px solid #6366f1;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Мобильное меню */
|
||||||
|
.mobileMenuButton {
|
||||||
|
display: none; /* По умолчанию скрыта на десктопе */
|
||||||
|
position: fixed;
|
||||||
|
top: 8px;
|
||||||
|
left: 12px;
|
||||||
|
z-index: 1000;
|
||||||
|
background: #6366f1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
min-width: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileOverlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для планшетов */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.menu {
|
||||||
|
width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project {
|
||||||
|
font-size: 20px;
|
||||||
|
padding: 24px 20px 12px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 10px 20px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 10px 20px 4px 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.menu {
|
||||||
|
position: fixed;
|
||||||
|
left: -250px;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
width: 250px;
|
||||||
|
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu.open {
|
||||||
|
transform: translateX(250px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileMenuButton {
|
||||||
|
display: flex; /* Показываем только на мобильных */
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileOverlay.open {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project {
|
||||||
|
font-size: 18px;
|
||||||
|
padding: 20px 16px 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 16px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 8px 16px 4px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.menu {
|
||||||
|
width: 280px;
|
||||||
|
left: -280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu.open {
|
||||||
|
transform: translateX(280px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 16px 12px 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
li {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 6px 12px 2px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch устройств */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
li {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobileMenuButton {
|
||||||
|
min-height: 30px;
|
||||||
|
min-width: 30px;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -5,9 +5,53 @@ body {
|
|||||||
sans-serif;
|
sans-serif;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||||
monospace;
|
monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Глобальные адаптивные стили */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Медиа-запросы для разных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
html {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
html {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch устройств */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
button, a, [role="button"] {
|
||||||
|
min-height: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Поддержка темной темы */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--background-color: #1a1a1a;
|
||||||
|
--text-color: #ffffff;
|
||||||
|
--border-color: #333333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
:root {
|
||||||
|
--background-color: #ffffff;
|
||||||
|
--text-color: #000000;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import styles from '../styles/UserModal.module.css';
|
import styles from '../styles/UserModal.module.css';
|
||||||
|
|
||||||
export default function CreateUserModal({ isOpen, onClose, user, roles, loading, onChange, onSave }) {
|
export default function CreateUserModal({ isOpen, onClose, user, loading, onChange, onSave }) {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
<h3 className={styles.title}>Добавить пользователя</h3>
|
<h3 className={styles.title}>Добавить пользователя</h3>
|
||||||
@ -14,20 +14,10 @@ export default function CreateUserModal({ isOpen, onClose, user, roles, loading,
|
|||||||
<input type="text" value={user.name} onChange={e => onChange({ ...user, name: e.target.value })} required className={styles.input} />
|
<input type="text" value={user.name} onChange={e => onChange({ ...user, name: e.target.value })} required className={styles.input} />
|
||||||
</label>
|
</label>
|
||||||
<label className={styles.label}>Роль
|
<label className={styles.label}>Роль
|
||||||
<select value={user.role_id} onChange={e => onChange({ ...user, role_id: Number(e.target.value) })} required className={styles.input}>
|
<select value={user.role} onChange={e => onChange({ ...user, role: e.target.value })} required className={styles.input}>
|
||||||
{roles.length === 0 ? (
|
<option value="user">Пользователь</option>
|
||||||
<option value="">Загрузка ролей...</option>
|
<option value="admin">Администратор</option>
|
||||||
) : (
|
|
||||||
roles.map(role => (
|
|
||||||
<option key={role.id} value={role.id}>{role.name}</option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
{process.env.NODE_ENV === 'development' && (
|
|
||||||
<div style={{ fontSize: '10px', color: '#666', marginTop: '4px' }}>
|
|
||||||
Debug: {roles.length} roles, user.role_id: {user.role_id}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
<label className={styles.label}>Пароль
|
<label className={styles.label}>Пароль
|
||||||
<input type="password" value={user.password} onChange={e => onChange({ ...user, password: e.target.value })} required className={styles.input} />
|
<input type="password" value={user.password} onChange={e => onChange({ ...user, password: e.target.value })} required className={styles.input} />
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import Modal from './Modal';
|
import Modal from './Modal';
|
||||||
import styles from '../styles/UserModal.module.css';
|
import styles from '../styles/UserModal.module.css';
|
||||||
|
|
||||||
export default function EditUserModal({ isOpen, onClose, user, roles, loading, onChange, onSave }) {
|
export default function EditUserModal({ isOpen, onClose, user, loading, onChange, onSave }) {
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
<h3 className={styles.title}>Редактировать пользователя</h3>
|
<h3 className={styles.title}>Редактировать пользователя</h3>
|
||||||
@ -14,20 +14,10 @@ export default function EditUserModal({ isOpen, onClose, user, roles, loading, o
|
|||||||
<input type="text" value={user.name} onChange={e => onChange({ ...user, name: e.target.value })} required className={styles.input} />
|
<input type="text" value={user.name} onChange={e => onChange({ ...user, name: e.target.value })} required className={styles.input} />
|
||||||
</label>
|
</label>
|
||||||
<label className={styles.label}>Роль
|
<label className={styles.label}>Роль
|
||||||
<select value={user.role_id} onChange={e => onChange({ ...user, role_id: Number(e.target.value) })} required className={styles.input}>
|
<select value={user.role} onChange={e => onChange({ ...user, role: e.target.value })} required className={styles.input}>
|
||||||
{roles.length === 0 ? (
|
<option value="user">Пользователь</option>
|
||||||
<option value="">Загрузка ролей...</option>
|
<option value="admin">Администратор</option>
|
||||||
) : (
|
|
||||||
roles.map(role => (
|
|
||||||
<option key={role.id} value={role.id}>{role.name}</option>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</select>
|
</select>
|
||||||
{process.env.NODE_ENV === 'development' && (
|
|
||||||
<div style={{ fontSize: '10px', color: '#666', marginTop: '4px' }}>
|
|
||||||
Debug: {roles.length} roles, user.role_id: {user.role_id}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</label>
|
</label>
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
||||||
|
|||||||
@ -3,11 +3,12 @@ import { useUser } from '../context/UserContext';
|
|||||||
import EditCampaignModal from '../modals/EditCampaignModal';
|
import EditCampaignModal from '../modals/EditCampaignModal';
|
||||||
import CreateCampaignModal from '../modals/CreateCampaignModal';
|
import CreateCampaignModal from '../modals/CreateCampaignModal';
|
||||||
import Paginator from '../components/Paginator';
|
import Paginator from '../components/Paginator';
|
||||||
|
import styles from '../styles/Common.module.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
function CampaignPage() {
|
function CampaignPage() {
|
||||||
const { token, user } = useUser();
|
const { token } = useUser();
|
||||||
const [campaigns, setCampaigns] = useState([]);
|
const [campaigns, setCampaigns] = useState([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -98,14 +99,14 @@ function CampaignPage() {
|
|||||||
|
|
||||||
const fetchSmtpServers = async () => {
|
const fetchSmtpServers = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/mail/smtp-servers?limit=1000', {
|
const res = await fetch('/api/mail/smtp-servers', {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (res.ok && Array.isArray(data)) {
|
if (res.ok && Array.isArray(data.rows)) {
|
||||||
setSmtpServers(data);
|
|
||||||
} else if (res.ok && Array.isArray(data.rows)) {
|
|
||||||
setSmtpServers(data.rows);
|
setSmtpServers(data.rows);
|
||||||
|
} else if (res.ok && Array.isArray(data)) {
|
||||||
|
setSmtpServers(data);
|
||||||
} else {
|
} else {
|
||||||
setSmtpServers([]);
|
setSmtpServers([]);
|
||||||
}
|
}
|
||||||
@ -136,10 +137,7 @@ function CampaignPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (c) => {
|
const handleEdit = (c) => {
|
||||||
setEditCampaign({
|
setEditCampaign(c);
|
||||||
...c,
|
|
||||||
smtp_server_ids: c.SmtpServers ? c.SmtpServers.map(s => s.id) : [],
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEditSave = async (e) => {
|
const handleEditSave = async (e) => {
|
||||||
@ -152,7 +150,7 @@ function CampaignPage() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ...editCampaign, user_id: user?.id, smtp_server_ids: editCampaign.smtp_server_ids })
|
body: JSON.stringify(editCampaign)
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -169,14 +167,7 @@ function CampaignPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setCreateCampaign({
|
setCreateCampaign({ group_id: '', template_version_id: '', subject_override: '', status: 'draft', scheduled_at: '' });
|
||||||
group_id: groups[0]?.id || '',
|
|
||||||
template_version_id: versions[0]?.id || '',
|
|
||||||
subject_override: '',
|
|
||||||
scheduled_at: '',
|
|
||||||
status: 'draft',
|
|
||||||
smtp_server_ids: [],
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSave = async (e) => {
|
const handleCreateSave = async (e) => {
|
||||||
@ -189,7 +180,7 @@ function CampaignPage() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ...createCampaign, user_id: user?.id, smtp_server_ids: createCampaign.smtp_server_ids })
|
body: JSON.stringify(createCampaign)
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -212,46 +203,71 @@ function CampaignPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
<div className={styles.pageHeader}>
|
||||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить кампанию</button>
|
<h2 className={styles.pageTitle}>Кампания</h2>
|
||||||
|
<div className={styles.pageActions}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className={`${styles.button} ${styles.buttonGradient}`}
|
||||||
|
>
|
||||||
|
+ Добавить кампанию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Кампания</h2>
|
|
||||||
{loading && <div>Загрузка...</div>}
|
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
<table className={styles.table}>
|
||||||
<thead style={{ background: '#f3f4f6' }}>
|
<thead className={styles.tableHeader}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>ID</th>
|
<th className={styles.tableHeaderCell}>ID</th>
|
||||||
<th style={thStyle}>Группа</th>
|
<th className={styles.tableHeaderCell}>Группа</th>
|
||||||
<th style={thStyle}>Версия шаблона</th>
|
<th className={styles.tableHeaderCell}>Версия шаблона</th>
|
||||||
<th style={thStyle}>Тема</th>
|
<th className={styles.tableHeaderCell}>Тема</th>
|
||||||
<th style={thStyle}>Статус</th>
|
<th className={styles.tableHeaderCell}>Статус</th>
|
||||||
<th style={thStyle}>Запланировано</th>
|
<th className={styles.tableHeaderCell}>Запланировано</th>
|
||||||
<th style={thStyle}></th>
|
<th className={styles.tableHeaderCell}>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{campaigns.map(c => (
|
{campaigns.map(c => (
|
||||||
<tr key={c.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr key={c.id} className={styles.tableRow}>
|
||||||
<td style={tdStyle}>{c.id}</td>
|
<td className={styles.tableCell}>{c.id}</td>
|
||||||
<td style={tdStyle}>{getGroupName(c.group_id)}</td>
|
<td className={styles.tableCell}>{getGroupName(c.group_id)}</td>
|
||||||
<td style={tdStyle}>{getVersionName(c.template_version_id)}</td>
|
<td className={styles.tableCell}>{getVersionName(c.template_version_id)}</td>
|
||||||
<td style={tdStyle}>{c.subject_override || ''}</td>
|
<td className={styles.tableCell}>{c.subject_override || ''}</td>
|
||||||
<td style={tdStyle}>{c.status}</td>
|
<td className={styles.tableCell}>{c.status}</td>
|
||||||
<td style={tdStyle}>{c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : ''}</td>
|
<td className={styles.tableCell}>{c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : ''}</td>
|
||||||
<td style={tdStyle}>
|
<td className={styles.tableCellActions}>
|
||||||
<button onClick={() => handleEdit(c)} style={btnStyle}>Редактировать</button>
|
<button
|
||||||
<button onClick={() => handleDelete(c.id)} style={btnStyle} disabled={deleteLoading === c.id}>
|
onClick={() => handleEdit(c)}
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(c.id)}
|
||||||
|
className={`${styles.button} ${styles.buttonDanger}`}
|
||||||
|
disabled={deleteLoading === c.id}
|
||||||
|
>
|
||||||
{deleteLoading === c.id ? 'Удаление...' : 'Удалить'}
|
{deleteLoading === c.id ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{campaigns.length === 0 && (
|
{campaigns.length === 0 && (
|
||||||
<tr><td colSpan={7} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
<tr>
|
||||||
|
<td colSpan={7} className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateIcon}>📧</div>
|
||||||
|
<div className={styles.emptyStateTitle}>Нет кампаний</div>
|
||||||
|
<div className={styles.emptyStateText}>Создайте первую кампанию для начала работы</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -263,6 +279,7 @@ function CampaignPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editCampaign && (
|
{editCampaign && (
|
||||||
<EditCampaignModal
|
<EditCampaignModal
|
||||||
isOpen={!!editCampaign}
|
isOpen={!!editCampaign}
|
||||||
@ -277,6 +294,7 @@ function CampaignPage() {
|
|||||||
getVersionName={getVersionName}
|
getVersionName={getVersionName}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createCampaign && (
|
{createCampaign && (
|
||||||
<CreateCampaignModal
|
<CreateCampaignModal
|
||||||
isOpen={!!createCampaign}
|
isOpen={!!createCampaign}
|
||||||
@ -295,9 +313,4 @@ function CampaignPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
|
||||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
|
||||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
|
||||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
|
||||||
|
|
||||||
export default CampaignPage;
|
export default CampaignPage;
|
||||||
@ -9,6 +9,7 @@ import UnsubscribedPage from './UnsubscribedPage';
|
|||||||
import GroupsPage from './GroupsPage';
|
import GroupsPage from './GroupsPage';
|
||||||
import DeliveryHistoryPage from './DeliveryHistoryPage';
|
import DeliveryHistoryPage from './DeliveryHistoryPage';
|
||||||
import CampaignPage from './CampaignPage';
|
import CampaignPage from './CampaignPage';
|
||||||
|
import styles from './Dashboard.module.css';
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const [active, setActive] = useState('smtp');
|
const [active, setActive] = useState('smtp');
|
||||||
@ -33,11 +34,11 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', minHeight: '100vh', background: '#f8fafc' }}>
|
<div className={styles.dashboard}>
|
||||||
<SideMenu active={active} onSelect={setActive} />
|
<SideMenu active={active} onSelect={setActive} />
|
||||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
|
<div className={styles.content}>
|
||||||
<Header user={user} onLogout={handleLogout} />
|
<Header user={user} onLogout={handleLogout} />
|
||||||
<div style={{ flex: 1, padding: 32 }}>
|
<div className={styles.pageContent}>
|
||||||
{renderPage()}
|
{renderPage()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
53
frontend/src/pages/Dashboard.module.css
Normal file
53
frontend/src/pages/Dashboard.module.css
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
.dashboard {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0; /* Предотвращает переполнение на мобильных */
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageContent {
|
||||||
|
flex: 1;
|
||||||
|
padding: 32px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для планшетов */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.pageContent {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageContent {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.pageContent {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch устройств */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.pageContent {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useUser } from '../context/UserContext';
|
import { useUser } from '../context/UserContext';
|
||||||
import Paginator from '../components/Paginator';
|
import Paginator from '../components/Paginator';
|
||||||
|
import styles from '../styles/Common.module.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -44,40 +45,50 @@ function DeliveryHistoryPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<h2>История отправок</h2>
|
<div className={styles.pageHeader}>
|
||||||
{loading && <div>Загрузка...</div>}
|
<h2 className={styles.pageTitle}>История отправок</h2>
|
||||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
<table className={styles.table}>
|
||||||
<thead style={{ background: '#f3f4f6' }}>
|
<thead className={styles.tableHeader}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>ID</th>
|
<th className={styles.tableHeaderCell}>ID</th>
|
||||||
<th style={thStyle}>Email</th>
|
<th className={styles.tableHeaderCell}>Email</th>
|
||||||
<th style={thStyle}>Статус</th>
|
<th className={styles.tableHeaderCell}>Статус</th>
|
||||||
<th style={thStyle}>Дата отправки</th>
|
<th className={styles.tableHeaderCell}>Дата отправки</th>
|
||||||
<th style={thStyle}>Открыто</th>
|
<th className={styles.tableHeaderCell}>Открыто</th>
|
||||||
<th style={thStyle}>Клик</th>
|
<th className={styles.tableHeaderCell}>Клик</th>
|
||||||
<th style={thStyle}>Ошибка</th>
|
<th className={styles.tableHeaderCell}>Ошибка</th>
|
||||||
<th style={thStyle}>Кампания</th>
|
<th className={styles.tableHeaderCell}>Кампания</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{logs.map(l => (
|
{logs.map(l => (
|
||||||
<tr key={l.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr key={l.id} className={styles.tableRow}>
|
||||||
<td style={tdStyle}>{l.id}</td>
|
<td className={styles.tableCell}>{l.id}</td>
|
||||||
<td style={tdStyle}>{l.Subscriber?.email || l.subscriber_id}</td>
|
<td className={styles.tableCell}>{l.Subscriber?.email || l.subscriber_id}</td>
|
||||||
<td style={tdStyle}>{l.status}</td>
|
<td className={styles.tableCell}>{l.status}</td>
|
||||||
<td style={tdStyle}>{l.sent_at ? new Date(l.sent_at).toLocaleString() : ''}</td>
|
<td className={styles.tableCell}>{l.sent_at ? new Date(l.sent_at).toLocaleString() : ''}</td>
|
||||||
<td style={tdStyle}>{l.opened_at ? new Date(l.opened_at).toLocaleString() : ''}</td>
|
<td className={styles.tableCell}>{l.opened_at ? new Date(l.opened_at).toLocaleString() : ''}</td>
|
||||||
<td style={tdStyle}>{l.clicked_at ? new Date(l.clicked_at).toLocaleString() : ''}</td>
|
<td className={styles.tableCell}>{l.clicked_at ? new Date(l.clicked_at).toLocaleString() : ''}</td>
|
||||||
<td style={tdStyle}>{l.error_message || ''}</td>
|
<td className={styles.tableCell}>{l.error_message || ''}</td>
|
||||||
<td style={tdStyle}>{l.campaign_id}</td>
|
<td className={styles.tableCell}>{l.campaign_id}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{logs.length === 0 && (
|
{logs.length === 0 && (
|
||||||
<tr><td colSpan={8} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
<tr>
|
||||||
|
<td colSpan={8} className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateIcon}>📊</div>
|
||||||
|
<div className={styles.emptyStateTitle}>Нет истории отправок</div>
|
||||||
|
<div className={styles.emptyStateText}>История отправок появится после запуска кампаний</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -93,7 +104,4 @@ function DeliveryHistoryPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
|
||||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
|
||||||
|
|
||||||
export default DeliveryHistoryPage;
|
export default DeliveryHistoryPage;
|
||||||
@ -4,11 +4,12 @@ import EditTemplateModal from '../modals/EditTemplateModal';
|
|||||||
import CreateTemplateModal from '../modals/CreateTemplateModal';
|
import CreateTemplateModal from '../modals/CreateTemplateModal';
|
||||||
import TemplateVersionsPage from './TemplateVersionsPage';
|
import TemplateVersionsPage from './TemplateVersionsPage';
|
||||||
import Paginator from '../components/Paginator';
|
import Paginator from '../components/Paginator';
|
||||||
|
import styles from '../styles/Common.module.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
function EmailTemplatesPage() {
|
function EmailTemplatesPage() {
|
||||||
const { token, user } = useUser();
|
const { token } = useUser();
|
||||||
const [templates, setTemplates] = useState([]);
|
const [templates, setTemplates] = useState([]);
|
||||||
const [total, setTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
@ -36,7 +37,7 @@ function EmailTemplatesPage() {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setError(data.error || 'Ошибка загрузки шаблонов');
|
setError(data.error || 'Ошибка загрузки');
|
||||||
setTemplates([]);
|
setTemplates([]);
|
||||||
setTotal(0);
|
setTotal(0);
|
||||||
} else {
|
} else {
|
||||||
@ -87,7 +88,7 @@ function EmailTemplatesPage() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ...editTemplate, user_id: user?.id })
|
body: JSON.stringify(editTemplate)
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -104,7 +105,7 @@ function EmailTemplatesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setCreateTemplate({ name: '' });
|
setCreateTemplate({ name: '', description: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSave = async (e) => {
|
const handleCreateSave = async (e) => {
|
||||||
@ -117,7 +118,7 @@ function EmailTemplatesPage() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ...createTemplate, user_id: user?.id })
|
body: JSON.stringify(createTemplate)
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -138,38 +139,65 @@ function EmailTemplatesPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
<div className={styles.pageHeader}>
|
||||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить шаблон</button>
|
<h2 className={styles.pageTitle}>Шаблоны писем</h2>
|
||||||
|
<div className={styles.pageActions}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className={`${styles.button} ${styles.buttonGradient}`}
|
||||||
|
>
|
||||||
|
+ Добавить шаблон
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Шаблоны писем</h2>
|
|
||||||
{loading && <div>Загрузка...</div>}
|
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
<table className={styles.table}>
|
||||||
<thead style={{ background: '#f3f4f6' }}>
|
<thead className={styles.tableHeader}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>ID</th>
|
<th className={styles.tableHeaderCell}>ID</th>
|
||||||
<th style={thStyle}>Название</th>
|
<th className={styles.tableHeaderCell}>Название</th>
|
||||||
<th style={thStyle}></th>
|
<th className={styles.tableHeaderCell}>Описание</th>
|
||||||
|
<th className={styles.tableHeaderCell}>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{templates.map(t => (
|
{templates.map(t => (
|
||||||
<tr key={t.id} style={{ borderBottom: '1px solid #e5e7eb', cursor: 'pointer' }} onClick={() => setSelectedTemplate(t)}>
|
<tr key={t.id} className={styles.tableRow} style={{ cursor: 'pointer' }} onClick={() => setSelectedTemplate(t)}>
|
||||||
<td style={tdStyle}>{t.id}</td>
|
<td className={styles.tableCell}>{t.id}</td>
|
||||||
<td style={tdStyle}>{t.name}</td>
|
<td className={styles.tableCell}>{t.name}</td>
|
||||||
<td style={tdStyle}>
|
<td className={styles.tableCell}>{t.description}</td>
|
||||||
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={btnStyle}>Редактировать</button>
|
<td className={styles.tableCellActions}>
|
||||||
<button onClick={e => { e.stopPropagation(); handleDelete(t.id); }} style={btnStyle} disabled={deleteLoading === t.id}>
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleEdit(t); }}
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDelete(t.id); }}
|
||||||
|
className={`${styles.button} ${styles.buttonDanger}`}
|
||||||
|
disabled={deleteLoading === t.id}
|
||||||
|
>
|
||||||
{deleteLoading === t.id ? 'Удаление...' : 'Удалить'}
|
{deleteLoading === t.id ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{templates.length === 0 && (
|
{templates.length === 0 && (
|
||||||
<tr><td colSpan={3} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
<tr>
|
||||||
|
<td colSpan={4} className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateIcon}>📝</div>
|
||||||
|
<div className={styles.emptyStateTitle}>Нет шаблонов</div>
|
||||||
|
<div className={styles.emptyStateText}>Создайте первый шаблон письма</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -181,6 +209,7 @@ function EmailTemplatesPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editTemplate && (
|
{editTemplate && (
|
||||||
<EditTemplateModal
|
<EditTemplateModal
|
||||||
isOpen={!!editTemplate}
|
isOpen={!!editTemplate}
|
||||||
@ -191,6 +220,7 @@ function EmailTemplatesPage() {
|
|||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createTemplate && (
|
{createTemplate && (
|
||||||
<CreateTemplateModal
|
<CreateTemplateModal
|
||||||
isOpen={!!createTemplate}
|
isOpen={!!createTemplate}
|
||||||
@ -205,9 +235,4 @@ function EmailTemplatesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
|
||||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
|
||||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
|
||||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
|
||||||
|
|
||||||
export default EmailTemplatesPage;
|
export default EmailTemplatesPage;
|
||||||
@ -5,6 +5,7 @@ import CreateGroupModal from '../modals/CreateGroupModal';
|
|||||||
import EditSubscriberModal from '../modals/EditSubscriberModal';
|
import EditSubscriberModal from '../modals/EditSubscriberModal';
|
||||||
import CreateSubscriberModal from '../modals/CreateSubscriberModal';
|
import CreateSubscriberModal from '../modals/CreateSubscriberModal';
|
||||||
import Paginator from '../components/Paginator';
|
import Paginator from '../components/Paginator';
|
||||||
|
import styles from '../styles/Common.module.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -139,40 +140,65 @@ function GroupsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
<div className={styles.pageHeader}>
|
||||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить группу</button>
|
<h2 className={styles.pageTitle}>Подписчики и группы</h2>
|
||||||
|
<div className={styles.pageActions}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className={`${styles.button} ${styles.buttonGradient}`}
|
||||||
|
>
|
||||||
|
+ Добавить группу
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Группы подписчиков</h2>
|
|
||||||
{loading && <div>Загрузка...</div>}
|
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
<table className={styles.table}>
|
||||||
<thead style={{ background: '#f3f4f6' }}>
|
<thead className={styles.tableHeader}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>ID</th>
|
<th className={styles.tableHeaderCell}>ID</th>
|
||||||
<th style={thStyle}>Название</th>
|
<th className={styles.tableHeaderCell}>Название</th>
|
||||||
<th style={thStyle}>Описание</th>
|
<th className={styles.tableHeaderCell}>Описание</th>
|
||||||
<th style={thStyle}></th>
|
<th className={styles.tableHeaderCell}>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{groups.map(g => (
|
{groups.map(g => (
|
||||||
<tr key={g.id} style={{ borderBottom: '1px solid #e5e7eb', cursor: 'pointer' }} onClick={() => setSelectedGroup(g)}>
|
<tr key={g.id} className={styles.tableRow} style={{ cursor: 'pointer' }} onClick={() => setSelectedGroup(g)}>
|
||||||
<td style={tdStyle}>{g.id}</td>
|
<td className={styles.tableCell}>{g.id}</td>
|
||||||
<td style={tdStyle}>{g.name}</td>
|
<td className={styles.tableCell}>{g.name}</td>
|
||||||
<td style={tdStyle}>{g.description}</td>
|
<td className={styles.tableCell}>{g.description}</td>
|
||||||
<td style={tdStyle}>
|
<td className={styles.tableCellActions}>
|
||||||
<button onClick={e => { e.stopPropagation(); handleEdit(g); }} style={btnStyle}>Редактировать</button>
|
<button
|
||||||
<button onClick={e => { e.stopPropagation(); handleDelete(g.id); }} style={btnStyle} disabled={deleteLoading === g.id}>
|
onClick={(e) => { e.stopPropagation(); handleEdit(g); }}
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleDelete(g.id); }}
|
||||||
|
className={`${styles.button} ${styles.buttonDanger}`}
|
||||||
|
disabled={deleteLoading === g.id}
|
||||||
|
>
|
||||||
{deleteLoading === g.id ? 'Удаление...' : 'Удалить'}
|
{deleteLoading === g.id ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{groups.length === 0 && (
|
{groups.length === 0 && (
|
||||||
<tr><td colSpan={4} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
<tr>
|
||||||
|
<td colSpan={4} className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateIcon}>👥</div>
|
||||||
|
<div className={styles.emptyStateTitle}>Нет групп</div>
|
||||||
|
<div className={styles.emptyStateText}>Создайте первую группу для организации подписчиков</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -184,6 +210,7 @@ function GroupsPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editGroup && (
|
{editGroup && (
|
||||||
<EditGroupModal
|
<EditGroupModal
|
||||||
isOpen={!!editGroup}
|
isOpen={!!editGroup}
|
||||||
@ -194,6 +221,7 @@ function GroupsPage() {
|
|||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createGroup && (
|
{createGroup && (
|
||||||
<CreateGroupModal
|
<CreateGroupModal
|
||||||
isOpen={!!createGroup}
|
isOpen={!!createGroup}
|
||||||
@ -349,43 +377,75 @@ function SubscribersInGroupPage({ group, onBack, token }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<button onClick={onBack} style={{ marginBottom: 18, background: 'none', border: 'none', color: '#6366f1', fontWeight: 600, fontSize: 16, cursor: 'pointer' }}>← Назад к группам</button>
|
<button
|
||||||
<h2>Подписчики группы: {group.name}</h2>
|
onClick={onBack}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
className={`${styles.button} ${styles.buttonSecondary}`}
|
||||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить подписчика</button>
|
style={{ marginBottom: 18 }}
|
||||||
|
>
|
||||||
|
← Назад к группам
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<h2 className={styles.pageTitle}>Подписчики группы: {group.name}</h2>
|
||||||
|
<div className={styles.pageActions}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className={`${styles.button} ${styles.buttonGradient}`}
|
||||||
|
>
|
||||||
|
+ Добавить подписчика
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading && <div>Загрузка...</div>}
|
|
||||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
<table className={styles.table}>
|
||||||
<thead style={{ background: '#f3f4f6' }}>
|
<thead className={styles.tableHeader}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>ID</th>
|
<th className={styles.tableHeaderCell}>ID</th>
|
||||||
<th style={thStyle}>Email</th>
|
<th className={styles.tableHeaderCell}>Email</th>
|
||||||
<th style={thStyle}>Имя</th>
|
<th className={styles.tableHeaderCell}>Имя</th>
|
||||||
<th style={thStyle}>Статус</th>
|
<th className={styles.tableHeaderCell}>Статус</th>
|
||||||
<th style={thStyle}></th>
|
<th className={styles.tableHeaderCell}>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{subs.map(gs => (
|
{subs.map(gs => (
|
||||||
<tr key={gs.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr key={gs.id} className={styles.tableRow}>
|
||||||
<td style={tdStyle}>{gs.Subscriber?.id || ''}</td>
|
<td className={styles.tableCell}>{gs.Subscriber?.id || ''}</td>
|
||||||
<td style={tdStyle}>{gs.Subscriber?.email || ''}</td>
|
<td className={styles.tableCell}>{gs.Subscriber?.email || ''}</td>
|
||||||
<td style={tdStyle}>{gs.Subscriber?.name || ''}</td>
|
<td className={styles.tableCell}>{gs.Subscriber?.name || ''}</td>
|
||||||
<td style={tdStyle}>{gs.Subscriber?.status || ''}</td>
|
<td className={styles.tableCell}>{gs.Subscriber?.status || ''}</td>
|
||||||
<td style={tdStyle}>
|
<td className={styles.tableCellActions}>
|
||||||
<button onClick={() => handleEdit(gs)} style={btnStyle}>Редактировать</button>
|
<button
|
||||||
<button onClick={() => handleDelete(gs.id)} style={btnStyle} disabled={deleteLoading === gs.id}>
|
onClick={() => handleEdit(gs)}
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(gs.id)}
|
||||||
|
className={`${styles.button} ${styles.buttonDanger}`}
|
||||||
|
disabled={deleteLoading === gs.id}
|
||||||
|
>
|
||||||
{deleteLoading === gs.id ? 'Удаление...' : 'Удалить'}
|
{deleteLoading === gs.id ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{subs.length === 0 && (
|
{subs.length === 0 && (
|
||||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
<tr>
|
||||||
|
<td colSpan={5} className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateIcon}>📧</div>
|
||||||
|
<div className={styles.emptyStateTitle}>Нет подписчиков</div>
|
||||||
|
<div className={styles.emptyStateText}>В этой группе пока нет подписчиков</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -397,6 +457,7 @@ function SubscribersInGroupPage({ group, onBack, token }) {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editSub && (
|
{editSub && (
|
||||||
<EditSubscriberModal
|
<EditSubscriberModal
|
||||||
isOpen={!!editSub}
|
isOpen={!!editSub}
|
||||||
@ -407,6 +468,7 @@ function SubscribersInGroupPage({ group, onBack, token }) {
|
|||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createSub && (
|
{createSub && (
|
||||||
<CreateSubscriberModal
|
<CreateSubscriberModal
|
||||||
isOpen={!!createSub}
|
isOpen={!!createSub}
|
||||||
@ -421,9 +483,4 @@ function SubscribersInGroupPage({ group, onBack, token }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
|
||||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
|
||||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
|
||||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
|
||||||
|
|
||||||
export default GroupsPage;
|
export default GroupsPage;
|
||||||
@ -4,12 +4,14 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: linear-gradient(135deg, #e0e7ff 0%, #f0fdfa 100%);
|
background: linear-gradient(135deg, #e0e7ff 0%, #f0fdfa 100%);
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form {
|
.form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 340px;
|
width: 340px;
|
||||||
|
max-width: 100%;
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
padding: 36px;
|
padding: 36px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@ -37,9 +39,12 @@
|
|||||||
outline: none;
|
outline: none;
|
||||||
transition: border 0.2s;
|
transition: border 0.2s;
|
||||||
background: #f8fafc;
|
background: #f8fafc;
|
||||||
|
min-height: 48px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input:focus {
|
.input:focus {
|
||||||
border: 1.5px solid #6366f1;
|
border: 1.5px solid #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.button {
|
.button {
|
||||||
@ -53,6 +58,16 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
transition: background 0.2s;
|
transition: background 0.2s;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:hover {
|
||||||
|
background: linear-gradient(90deg, #4f46e5 0%, #0891b2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.button:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
@ -60,4 +75,94 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
margin-top: -10px;
|
margin-top: -10px;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для планшетов */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.form {
|
||||||
|
width: 320px;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 26px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.wrapper {
|
||||||
|
padding: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 28px 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.wrapper {
|
||||||
|
padding: 8px;
|
||||||
|
padding-top: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
padding: 24px 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch устройств */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.input {
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -3,6 +3,7 @@ import { useUser } from '../context/UserContext';
|
|||||||
import EditSmtpModal from '../modals/EditSmtpModal';
|
import EditSmtpModal from '../modals/EditSmtpModal';
|
||||||
import CreateSmtpModal from '../modals/CreateSmtpModal';
|
import CreateSmtpModal from '../modals/CreateSmtpModal';
|
||||||
import Paginator from '../components/Paginator';
|
import Paginator from '../components/Paginator';
|
||||||
|
import styles from '../styles/Common.module.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -117,9 +118,7 @@ function SmtpServersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setCreateServer({
|
setCreateServer({ name: '', host: '', port: 587, secure: false, username: '', password: '', from_email: '', from_name: '' });
|
||||||
name: '', host: '', port: 587, secure: false, username: '', password: '', from_email: ''
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSave = async (e) => {
|
const handleCreateSave = async (e) => {
|
||||||
@ -130,14 +129,13 @@ function SmtpServersPage() {
|
|||||||
}
|
}
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
try {
|
try {
|
||||||
const { group_id, ...serverData } = createServer;
|
|
||||||
const res = await fetch('/api/mail/smtp-servers', {
|
const res = await fetch('/api/mail/smtp-servers', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${token}`
|
Authorization: `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ...serverData, user_id: user?.id })
|
body: JSON.stringify({ ...createServer, user_id: user?.id })
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -154,48 +152,73 @@ function SmtpServersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
<div className={styles.pageHeader}>
|
||||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить SMTP-сервер</button>
|
<h2 className={styles.pageTitle}>SMTP-серверы</h2>
|
||||||
|
<div className={styles.pageActions}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className={`${styles.button} ${styles.buttonGradient}`}
|
||||||
|
>
|
||||||
|
+ Добавить SMTP-сервер
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>SMTP-серверы</h2>
|
|
||||||
{loading && <div>Загрузка...</div>}
|
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
<table className={styles.table}>
|
||||||
<thead style={{ background: '#f3f4f6' }}>
|
<thead className={styles.tableHeader}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>ID</th>
|
<th className={styles.tableHeaderCell}>ID</th>
|
||||||
<th style={thStyle}>Название</th>
|
<th className={styles.tableHeaderCell}>Название</th>
|
||||||
<th style={thStyle}>Host</th>
|
<th className={styles.tableHeaderCell}>Host</th>
|
||||||
<th style={thStyle}>Port</th>
|
<th className={styles.tableHeaderCell}>Port</th>
|
||||||
<th style={thStyle}>Secure</th>
|
<th className={styles.tableHeaderCell}>Secure</th>
|
||||||
<th style={thStyle}>Пользователь</th>
|
<th className={styles.tableHeaderCell}>Пользователь</th>
|
||||||
<th style={thStyle}>Отправитель</th>
|
<th className={styles.tableHeaderCell}>Отправитель</th>
|
||||||
<th style={thStyle}></th>
|
<th className={styles.tableHeaderCell}>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{servers.map(s => (
|
{servers.map(s => (
|
||||||
<tr key={s.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr key={s.id} className={styles.tableRow}>
|
||||||
<td style={tdStyle}>{s.id}</td>
|
<td className={styles.tableCell}>{s.id}</td>
|
||||||
<td style={tdStyle}>{s.name}</td>
|
<td className={styles.tableCell}>{s.name}</td>
|
||||||
<td style={tdStyle}>{s.host}</td>
|
<td className={styles.tableCell}>{s.host}</td>
|
||||||
<td style={tdStyle}>{s.port}</td>
|
<td className={styles.tableCell}>{s.port}</td>
|
||||||
<td style={tdStyle}>{s.secure ? 'Да' : 'Нет'}</td>
|
<td className={styles.tableCell}>{s.secure ? 'Да' : 'Нет'}</td>
|
||||||
<td style={tdStyle}>{s.username}</td>
|
<td className={styles.tableCell}>{s.username}</td>
|
||||||
<td style={tdStyle}>{s.from_email}</td>
|
<td className={styles.tableCell}>{s.from_email}</td>
|
||||||
<td style={tdStyle}>
|
<td className={styles.tableCellActions}>
|
||||||
<button onClick={() => handleEdit(s)} style={btnStyle}>Редактировать</button>
|
<button
|
||||||
<button onClick={() => handleDelete(s.id)} style={btnStyle} disabled={deleteLoading === s.id}>
|
onClick={() => handleEdit(s)}
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(s.id)}
|
||||||
|
className={`${styles.button} ${styles.buttonDanger}`}
|
||||||
|
disabled={deleteLoading === s.id}
|
||||||
|
>
|
||||||
{deleteLoading === s.id ? 'Удаление...' : 'Удалить'}
|
{deleteLoading === s.id ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{servers.length === 0 && (
|
{servers.length === 0 && (
|
||||||
<tr><td colSpan={8} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
<tr>
|
||||||
|
<td colSpan={8} className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateIcon}>📧</div>
|
||||||
|
<div className={styles.emptyStateTitle}>Нет SMTP-серверов</div>
|
||||||
|
<div className={styles.emptyStateText}>Добавьте первый SMTP-сервер для начала работы</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -207,6 +230,7 @@ function SmtpServersPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editServer && (
|
{editServer && (
|
||||||
<EditSmtpModal
|
<EditSmtpModal
|
||||||
isOpen={!!editServer}
|
isOpen={!!editServer}
|
||||||
@ -217,6 +241,7 @@ function SmtpServersPage() {
|
|||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createServer && (
|
{createServer && (
|
||||||
<CreateSmtpModal
|
<CreateSmtpModal
|
||||||
isOpen={!!createServer}
|
isOpen={!!createServer}
|
||||||
@ -231,9 +256,4 @@ function SmtpServersPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
|
||||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
|
||||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
|
||||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
|
||||||
|
|
||||||
export default SmtpServersPage;
|
export default SmtpServersPage;
|
||||||
@ -1,5 +1,7 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import CreateTemplateVersionModal from '../modals/CreateTemplateVersionModal';
|
import CreateTemplateVersionModal from '../modals/CreateTemplateVersionModal';
|
||||||
|
import Paginator from '../components/Paginator';
|
||||||
|
import styles from '../styles/Common.module.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -128,58 +130,102 @@ export default function TemplateVersionsPage({ template, onBack, token }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<button onClick={onBack} style={{ marginBottom: 18, background: 'none', border: 'none', color: '#6366f1', fontWeight: 600, fontSize: 16, cursor: 'pointer' }}>← Назад к шаблонам</button>
|
<button
|
||||||
<h2>Версии шаблона: {template.name}</h2>
|
onClick={onBack}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
className={`${styles.button} ${styles.buttonSecondary}`}
|
||||||
<button onClick={handleAddVersion} style={addBtnStyle}>+ Добавить версию</button>
|
style={{ marginBottom: 18 }}
|
||||||
|
>
|
||||||
|
← Назад к шаблонам
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className={styles.pageHeader}>
|
||||||
|
<h2 className={styles.pageTitle}>Версии шаблона: {template.name}</h2>
|
||||||
|
<div className={styles.pageActions}>
|
||||||
|
<button
|
||||||
|
onClick={handleAddVersion}
|
||||||
|
className={`${styles.button} ${styles.buttonGradient}`}
|
||||||
|
>
|
||||||
|
+ Добавить версию
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{loading && <div>Загрузка...</div>}
|
|
||||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||||
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
<table className={styles.table}>
|
||||||
<thead style={{ background: '#f3f4f6' }}>
|
<thead className={styles.tableHeader}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>ID</th>
|
<th className={styles.tableHeaderCell}>ID</th>
|
||||||
<th style={thStyle}>Версия</th>
|
<th className={styles.tableHeaderCell}>Версия</th>
|
||||||
<th style={thStyle}>Тема</th>
|
<th className={styles.tableHeaderCell}>Тема</th>
|
||||||
<th style={thStyle}>HTML</th>
|
<th className={styles.tableHeaderCell}>HTML</th>
|
||||||
<th style={thStyle}>Текст</th>
|
<th className={styles.tableHeaderCell}>Текст</th>
|
||||||
<th style={thStyle}></th>
|
<th className={styles.tableHeaderCell}>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{versions.map(v => (
|
{versions.map(v => (
|
||||||
<tr key={v.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr key={v.id} className={styles.tableRow}>
|
||||||
<td style={tdStyle}>{v.id}</td>
|
<td className={styles.tableCell}>{v.id}</td>
|
||||||
<td style={tdStyle}>{v.version_number}</td>
|
<td className={styles.tableCell}>{v.version_number}</td>
|
||||||
<td style={tdStyle}>{v.subject}</td>
|
<td className={styles.tableCell}>{v.subject}</td>
|
||||||
<td style={tdStyle}>{v.body_html ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
|
<td className={styles.tableCell}>
|
||||||
<td style={tdStyle}>{v.body_text ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
|
{v.body_html ?
|
||||||
<td style={tdStyle}>
|
<span style={{ color: '#10b981' }}>Есть</span> :
|
||||||
<button onClick={() => handleEditVersion(v)} style={btnStyle}>Редактировать</button>
|
<span style={{ color: '#ef4444' }}>Нет</span>
|
||||||
<button onClick={() => handleDeleteVersion(v.id)} style={btnStyle} disabled={deleteLoading === v.id}>
|
}
|
||||||
|
</td>
|
||||||
|
<td className={styles.tableCell}>
|
||||||
|
{v.body_text ?
|
||||||
|
<span style={{ color: '#10b981' }}>Есть</span> :
|
||||||
|
<span style={{ color: '#ef4444' }}>Нет</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td className={styles.tableCellActions}>
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditVersion(v)}
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteVersion(v.id)}
|
||||||
|
className={`${styles.button} ${styles.buttonDanger}`}
|
||||||
|
disabled={deleteLoading === v.id}
|
||||||
|
>
|
||||||
{deleteLoading === v.id ? 'Удаление...' : 'Удалить'}
|
{deleteLoading === v.id ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{versions.length === 0 && (
|
{versions.length === 0 && (
|
||||||
<tr><td colSpan={6} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет версий</td></tr>
|
<tr>
|
||||||
|
<td colSpan={6} className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateIcon}>📄</div>
|
||||||
|
<div className={styles.emptyStateTitle}>Нет версий</div>
|
||||||
|
<div className={styles.emptyStateText}>Создайте первую версию для этого шаблона</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
{/* Пагинация если нужно */}
|
|
||||||
{total > PAGE_SIZE && (
|
{total > PAGE_SIZE && (
|
||||||
<div style={{ marginTop: 16 }}>
|
<Paginator
|
||||||
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} style={btnStyle}>Назад</button>
|
page={page}
|
||||||
<span style={{ margin: '0 12px' }}>Страница {page}</span>
|
total={total}
|
||||||
<button onClick={() => setPage(p => p + 1)} disabled={page * PAGE_SIZE >= total} style={btnStyle}>Вперёд</button>
|
pageSize={PAGE_SIZE}
|
||||||
</div>
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createVersionModal && (
|
{createVersionModal && (
|
||||||
<CreateTemplateVersionModal
|
<CreateTemplateVersionModal
|
||||||
isOpen={createVersionModal}
|
isOpen={createVersionModal}
|
||||||
@ -188,6 +234,7 @@ export default function TemplateVersionsPage({ template, onBack, token }) {
|
|||||||
onSave={handleCreateVersionSave}
|
onSave={handleCreateVersionSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editVersion && (
|
{editVersion && (
|
||||||
<CreateTemplateVersionModal
|
<CreateTemplateVersionModal
|
||||||
isOpen={!!editVersion}
|
isOpen={!!editVersion}
|
||||||
@ -199,9 +246,4 @@ export default function TemplateVersionsPage({ template, onBack, token }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
|
||||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
|
||||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
|
||||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
|
||||||
@ -3,6 +3,7 @@ import { useUser } from '../context/UserContext';
|
|||||||
import EditUnsubModal from '../modals/EditUnsubModal';
|
import EditUnsubModal from '../modals/EditUnsubModal';
|
||||||
import CreateUnsubModal from '../modals/CreateUnsubModal';
|
import CreateUnsubModal from '../modals/CreateUnsubModal';
|
||||||
import Paginator from '../components/Paginator';
|
import Paginator from '../components/Paginator';
|
||||||
|
import styles from '../styles/Common.module.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -85,7 +86,7 @@ function UnsubscribedPage() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ ...editSub, status: 'unsubscribed' })
|
body: JSON.stringify(editSub)
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@ -102,7 +103,7 @@ function UnsubscribedPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setCreateSub({ email: '', name: '', status: 'unsubscribed' });
|
setCreateSub({ email: '', name: '' });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSave = async (e) => {
|
const handleCreateSave = async (e) => {
|
||||||
@ -132,42 +133,67 @@ function UnsubscribedPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
<div className={styles.pageHeader}>
|
||||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить запись</button>
|
<h2 className={styles.pageTitle}>Отписались</h2>
|
||||||
|
<div className={styles.pageActions}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className={`${styles.button} ${styles.buttonGradient}`}
|
||||||
|
>
|
||||||
|
+ Добавить запись
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Отписались</h2>
|
|
||||||
{loading && <div>Загрузка...</div>}
|
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||||
{error && <div style={{ color: 'red' }}>{error}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
|
|
||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
<table className={styles.table}>
|
||||||
<thead style={{ background: '#f3f4f6' }}>
|
<thead className={styles.tableHeader}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>ID</th>
|
<th className={styles.tableHeaderCell}>ID</th>
|
||||||
<th style={thStyle}>Email</th>
|
<th className={styles.tableHeaderCell}>Email</th>
|
||||||
<th style={thStyle}>Имя</th>
|
<th className={styles.tableHeaderCell}>Имя</th>
|
||||||
<th style={thStyle}>Дата отписки</th>
|
<th className={styles.tableHeaderCell}>Дата отписки</th>
|
||||||
<th style={thStyle}></th>
|
<th className={styles.tableHeaderCell}>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{subs.map(s => (
|
{subs.map(s => (
|
||||||
<tr key={s.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr key={s.id} className={styles.tableRow}>
|
||||||
<td style={tdStyle}>{s.id}</td>
|
<td className={styles.tableCell}>{s.id}</td>
|
||||||
<td style={tdStyle}>{s.email}</td>
|
<td className={styles.tableCell}>{s.email}</td>
|
||||||
<td style={tdStyle}>{s.name}</td>
|
<td className={styles.tableCell}>{s.name}</td>
|
||||||
<td style={tdStyle}>{s.unsubscribed_at ? new Date(s.unsubscribed_at).toLocaleString() : ''}</td>
|
<td className={styles.tableCell}>{s.unsubscribed_at ? new Date(s.unsubscribed_at).toLocaleString() : ''}</td>
|
||||||
<td style={tdStyle}>
|
<td className={styles.tableCellActions}>
|
||||||
<button onClick={() => handleEdit(s)} style={btnStyle}>Редактировать</button>
|
<button
|
||||||
<button onClick={() => handleDelete(s.id)} style={btnStyle} disabled={deleteLoading === s.id}>
|
onClick={() => handleEdit(s)}
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(s.id)}
|
||||||
|
className={`${styles.button} ${styles.buttonDanger}`}
|
||||||
|
disabled={deleteLoading === s.id}
|
||||||
|
>
|
||||||
{deleteLoading === s.id ? 'Удаление...' : 'Удалить'}
|
{deleteLoading === s.id ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{subs.length === 0 && (
|
{subs.length === 0 && (
|
||||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
<tr>
|
||||||
|
<td colSpan={5} className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateIcon}>🚫</div>
|
||||||
|
<div className={styles.emptyStateTitle}>Нет отписок</div>
|
||||||
|
<div className={styles.emptyStateText}>Список отписавшихся пользователей пуст</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@ -179,6 +205,7 @@ function UnsubscribedPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editSub && (
|
{editSub && (
|
||||||
<EditUnsubModal
|
<EditUnsubModal
|
||||||
isOpen={!!editSub}
|
isOpen={!!editSub}
|
||||||
@ -189,6 +216,7 @@ function UnsubscribedPage() {
|
|||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createSub && (
|
{createSub && (
|
||||||
<CreateUnsubModal
|
<CreateUnsubModal
|
||||||
isOpen={!!createSub}
|
isOpen={!!createSub}
|
||||||
@ -203,9 +231,4 @@ function UnsubscribedPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
|
||||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
|
||||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
|
||||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
|
||||||
|
|
||||||
export default UnsubscribedPage;
|
export default UnsubscribedPage;
|
||||||
@ -3,36 +3,31 @@ import { useUser } from '../context/UserContext';
|
|||||||
import EditUserModal from '../modals/EditUserModal';
|
import EditUserModal from '../modals/EditUserModal';
|
||||||
import CreateUserModal from '../modals/CreateUserModal';
|
import CreateUserModal from '../modals/CreateUserModal';
|
||||||
import Paginator from '../components/Paginator';
|
import Paginator from '../components/Paginator';
|
||||||
|
import styles from '../styles/Common.module.css';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
function UsersPage() {
|
function UsersPage() {
|
||||||
const { token } = useUser();
|
const { token } = useUser();
|
||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [usersTotal, setUsersTotal] = useState(0);
|
const [total, setTotal] = useState(0);
|
||||||
const [usersPage, setUsersPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [usersLoading, setUsersLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [usersError, setUsersError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [editUser, setEditUser] = useState(null);
|
const [editUser, setEditUser] = useState(null);
|
||||||
const [editLoading, setEditLoading] = useState(false);
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(null);
|
const [deleteLoading, setDeleteLoading] = useState(null);
|
||||||
const [createUser, setCreateUser] = useState(null);
|
const [createUser, setCreateUser] = useState(null);
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [roles, setRoles] = useState([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers(usersPage);
|
fetchUsers(page);
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [usersPage]);
|
}, [page]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchRoles();
|
|
||||||
// eslint-disable-next-line
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchUsers = async (page) => {
|
const fetchUsers = async (page) => {
|
||||||
setUsersLoading(true);
|
setLoading(true);
|
||||||
setUsersError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
const offset = (page - 1) * PAGE_SIZE;
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
const res = await fetch(`/api/auth/users?limit=${PAGE_SIZE}&offset=${offset}`, {
|
const res = await fetch(`/api/auth/users?limit=${PAGE_SIZE}&offset=${offset}`, {
|
||||||
@ -40,41 +35,19 @@ function UsersPage() {
|
|||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
setUsersError(data.error || 'Ошибка загрузки пользователей');
|
setError(data.error || 'Ошибка загрузки');
|
||||||
setUsers([]);
|
setUsers([]);
|
||||||
setUsersTotal(0);
|
setTotal(0);
|
||||||
} else {
|
} else {
|
||||||
setUsers(Array.isArray(data) ? data : data.rows || []);
|
setUsers(Array.isArray(data.rows) ? data.rows : []);
|
||||||
setUsersTotal(data.count || (Array.isArray(data) ? data.length : 0));
|
setTotal(data.count || 0);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setUsersError('Ошибка сети');
|
setError('Ошибка сети');
|
||||||
setUsers([]);
|
setUsers([]);
|
||||||
setUsersTotal(0);
|
setTotal(0);
|
||||||
} finally {
|
} finally {
|
||||||
setUsersLoading(false);
|
setLoading(false);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchRoles = async () => {
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/auth/roles', {
|
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
|
||||||
});
|
|
||||||
const data = await res.json();
|
|
||||||
console.log('Roles API response:', data); // Отладочная информация
|
|
||||||
if (res.ok) {
|
|
||||||
// API всегда возвращает объект с rows и count
|
|
||||||
const rolesData = data.rows || [];
|
|
||||||
console.log('Setting roles:', rolesData); // Отладочная информация
|
|
||||||
setRoles(rolesData);
|
|
||||||
} else {
|
|
||||||
console.error('Roles API error:', data); // Отладочная информация
|
|
||||||
setRoles([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Roles fetch error:', error); // Отладочная информация
|
|
||||||
setRoles([]);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -90,7 +63,7 @@ function UsersPage() {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
alert(data.error || 'Ошибка удаления');
|
alert(data.error || 'Ошибка удаления');
|
||||||
} else {
|
} else {
|
||||||
fetchUsers(usersPage);
|
fetchUsers(page);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка сети');
|
alert('Ошибка сети');
|
||||||
@ -113,18 +86,14 @@ function UsersPage() {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {})
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(editUser)
|
||||||
email: editUser.email,
|
|
||||||
name: editUser.name,
|
|
||||||
role_id: editUser.role_id
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
alert(data.error || 'Ошибка обновления');
|
alert(data.error || 'Ошибка обновления');
|
||||||
} else {
|
} else {
|
||||||
setEditUser(null);
|
setEditUser(null);
|
||||||
fetchUsers(usersPage);
|
fetchUsers(page);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка сети');
|
alert('Ошибка сети');
|
||||||
@ -134,12 +103,7 @@ function UsersPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
// Убеждаемся, что у нас есть роли перед созданием пользователя
|
setCreateUser({ email: '', password: '', name: '', role: 'user' });
|
||||||
if (roles.length === 0) {
|
|
||||||
alert('Загрузка ролей... Пожалуйста, подождите.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCreateUser({ email: '', name: '', role_id: roles[0]?.id || 1, password: '' });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSave = async (e) => {
|
const handleCreateSave = async (e) => {
|
||||||
@ -159,7 +123,7 @@ function UsersPage() {
|
|||||||
alert(data.error || 'Ошибка создания');
|
alert(data.error || 'Ошибка создания');
|
||||||
} else {
|
} else {
|
||||||
setCreateUser(null);
|
setCreateUser(null);
|
||||||
fetchUsers(usersPage);
|
fetchUsers(page);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка сети');
|
alert('Ошибка сети');
|
||||||
@ -168,99 +132,103 @@ function UsersPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRoleName = (role_id) => {
|
|
||||||
const role = roles.find(r => r.id === role_id);
|
|
||||||
return role ? role.name : role_id;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className={styles.container}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
<div className={styles.pageHeader}>
|
||||||
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить пользователя</button>
|
<h2 className={styles.pageTitle}>Управление пользователями</h2>
|
||||||
|
<div className={styles.pageActions}>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
className={`${styles.button} ${styles.buttonGradient}`}
|
||||||
|
>
|
||||||
|
+ Добавить пользователя
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h2>Пользователи</h2>
|
|
||||||
{usersLoading && <div>Загрузка...</div>}
|
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||||||
{usersError && <div style={{ color: 'red' }}>{usersError}</div>}
|
{error && <div className={styles.error}>{error}</div>}
|
||||||
{!usersLoading && !usersError && (
|
|
||||||
|
{!loading && !error && (
|
||||||
<>
|
<>
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
|
<table className={styles.table}>
|
||||||
<thead style={{ background: '#f3f4f6' }}>
|
<thead className={styles.tableHeader}>
|
||||||
<tr>
|
<tr>
|
||||||
<th style={thStyle}>ID</th>
|
<th className={styles.tableHeaderCell}>ID</th>
|
||||||
<th style={thStyle}>Email</th>
|
<th className={styles.tableHeaderCell}>Email</th>
|
||||||
<th style={thStyle}>Имя</th>
|
<th className={styles.tableHeaderCell}>Имя</th>
|
||||||
<th style={thStyle}>Роль</th>
|
<th className={styles.tableHeaderCell}>Роль</th>
|
||||||
<th style={thStyle}></th>
|
<th className={styles.tableHeaderCell}>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{users.map(u => (
|
{users.map(u => (
|
||||||
<tr key={u.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr key={u.id} className={styles.tableRow}>
|
||||||
<td style={tdStyle}>{u.id}</td>
|
<td className={styles.tableCell}>{u.id}</td>
|
||||||
<td style={tdStyle}>{u.email}</td>
|
<td className={styles.tableCell}>{u.email}</td>
|
||||||
<td style={tdStyle}>{u.name}</td>
|
<td className={styles.tableCell}>{u.name}</td>
|
||||||
<td style={tdStyle}>{getRoleName(u.role_id)}</td>
|
<td className={styles.tableCell}>{u.role}</td>
|
||||||
<td style={tdStyle}>
|
<td className={styles.tableCellActions}>
|
||||||
<button onClick={() => handleEdit(u)} style={btnStyle}>Редактировать</button>
|
<button
|
||||||
<button onClick={() => handleDelete(u.id)} style={btnStyle} disabled={deleteLoading === u.id}>
|
onClick={() => handleEdit(u)}
|
||||||
|
className={`${styles.button} ${styles.buttonPrimary}`}
|
||||||
|
style={{ marginRight: '8px' }}
|
||||||
|
>
|
||||||
|
Редактировать
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(u.id)}
|
||||||
|
className={`${styles.button} ${styles.buttonDanger}`}
|
||||||
|
disabled={deleteLoading === u.id}
|
||||||
|
>
|
||||||
{deleteLoading === u.id ? 'Удаление...' : 'Удалить'}
|
{deleteLoading === u.id ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{users.length === 0 && (
|
{users.length === 0 && (
|
||||||
<tr><td colSpan={5} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
|
<tr>
|
||||||
|
<td colSpan={5} className={styles.emptyState}>
|
||||||
|
<div className={styles.emptyStateIcon}>👤</div>
|
||||||
|
<div className={styles.emptyStateTitle}>Нет пользователей</div>
|
||||||
|
<div className={styles.emptyStateText}>Создайте первого пользователя</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<Paginator
|
<Paginator
|
||||||
page={usersPage}
|
page={page}
|
||||||
total={usersTotal}
|
total={total}
|
||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
onPageChange={setUsersPage}
|
onPageChange={setPage}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editUser && (
|
{editUser && (
|
||||||
<EditUserModal
|
<EditUserModal
|
||||||
isOpen={!!editUser}
|
isOpen={!!editUser}
|
||||||
onClose={() => setEditUser(null)}
|
onClose={() => setEditUser(null)}
|
||||||
user={editUser}
|
user={editUser}
|
||||||
roles={roles}
|
|
||||||
loading={editLoading}
|
loading={editLoading}
|
||||||
onChange={setEditUser}
|
onChange={setEditUser}
|
||||||
onSave={handleEditSave}
|
onSave={handleEditSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{createUser && (
|
{createUser && (
|
||||||
<CreateUserModal
|
<CreateUserModal
|
||||||
isOpen={!!createUser}
|
isOpen={!!createUser}
|
||||||
onClose={() => setCreateUser(null)}
|
onClose={() => setCreateUser(null)}
|
||||||
user={createUser}
|
user={createUser}
|
||||||
roles={roles}
|
|
||||||
loading={createLoading}
|
loading={createLoading}
|
||||||
onChange={setCreateUser}
|
onChange={setCreateUser}
|
||||||
onSave={handleCreateSave}
|
onSave={handleCreateSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Отладочная информация */}
|
|
||||||
{process.env.NODE_ENV === 'development' && (
|
|
||||||
<div style={{ marginTop: 20, padding: 10, background: '#f0f0f0', fontSize: '12px' }}>
|
|
||||||
<strong>Debug Info:</strong><br/>
|
|
||||||
Roles count: {roles.length}<br/>
|
|
||||||
Roles: {JSON.stringify(roles.slice(0, 3))}<br/>
|
|
||||||
Edit user: {editUser ? JSON.stringify(editUser) : 'null'}<br/>
|
|
||||||
Create user: {createUser ? JSON.stringify(createUser) : 'null'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
|
||||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
|
||||||
const btnStyle = { marginRight: 8, padding: '6px 12px', borderRadius: 6, border: 'none', background: '#6366f1', color: '#fff', cursor: 'pointer', fontWeight: 500 };
|
|
||||||
|
|
||||||
const addBtnStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', border: 'none', borderRadius: 8, padding: '10px 22px', fontSize: 16, fontWeight: 600, cursor: 'pointer', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)', transition: 'background 0.2s' };
|
|
||||||
|
|
||||||
export default UsersPage;
|
export default UsersPage;
|
||||||
386
frontend/src/styles/Common.module.css
Normal file
386
frontend/src/styles/Common.module.css
Normal file
@ -0,0 +1,386 @@
|
|||||||
|
/* Общие стили для таблиц */
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin-top: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeader {
|
||||||
|
background: #f3f4f6;
|
||||||
|
font-weight: 600;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeaderCell {
|
||||||
|
padding: 12px 16px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableRow:hover {
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCell {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #374151;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCellActions {
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: #fff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Общие стили для кнопок */
|
||||||
|
.button {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPrimary {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonPrimary:hover {
|
||||||
|
background: #4f46e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSecondary {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #374151;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSecondary:hover {
|
||||||
|
background: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonDanger {
|
||||||
|
background: #ef4444;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonDanger:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSuccess {
|
||||||
|
background: #10b981;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonSuccess:hover {
|
||||||
|
background: #059669;
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonGradient {
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
.buttonGradient:hover {
|
||||||
|
background: linear-gradient(90deg, #4f46e5 0%, #0891b2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Общие стили для форм */
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formGroup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formInput {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1.5px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formInput:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formTextarea {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1.5px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formTextarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.formSelect {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1.5px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formSelect:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Общие стили для контейнеров */
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #111827;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageActions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для планшетов */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.tableHeaderCell,
|
||||||
|
.tableCell {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formInput,
|
||||||
|
.formTextarea,
|
||||||
|
.formSelect {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.table {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableHeaderCell,
|
||||||
|
.tableCell {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCellActions {
|
||||||
|
padding: 6px 10px;
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formInput,
|
||||||
|
.formTextarea,
|
||||||
|
.formSelect {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageHeader {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageActions {
|
||||||
|
justify-content: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageActions .button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.tableHeaderCell,
|
||||||
|
.tableCell {
|
||||||
|
padding: 6px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCellActions {
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formInput,
|
||||||
|
.formTextarea,
|
||||||
|
.formSelect {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pageTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch устройств */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.button {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.formInput,
|
||||||
|
.formTextarea,
|
||||||
|
.formSelect {
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tableCellActions {
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для состояний загрузки */
|
||||||
|
.loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ef4444;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success {
|
||||||
|
color: #10b981;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для пустого состояния */
|
||||||
|
.emptyState {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyStateIcon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyStateTitle {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emptyStateText {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
@ -16,9 +17,12 @@
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
padding: 32px 28px 24px 28px;
|
padding: 32px 28px 24px 28px;
|
||||||
min-width: 340px;
|
min-width: 340px;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
box-shadow: 0 12px 48px 0 rgba(31,38,135,0.22);
|
box-shadow: 0 12px 48px 0 rgba(31,38,135,0.22);
|
||||||
position: relative;
|
position: relative;
|
||||||
animation: modalIn 0.18s cubic-bezier(.4,1.3,.6,1);
|
animation: modalIn 0.18s cubic-bezier(.4,1.3,.6,1);
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.closeBtn {
|
.closeBtn {
|
||||||
@ -36,9 +40,87 @@
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
transition: opacity 0.2s;
|
transition: opacity 0.2s;
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes modalIn {
|
@keyframes modalIn {
|
||||||
from { transform: translateY(40px) scale(0.98); opacity: 0; }
|
from { transform: translateY(40px) scale(0.98); opacity: 0; }
|
||||||
to { transform: none; opacity: 1; }
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для планшетов */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.modal {
|
||||||
|
padding: 28px 24px 20px 24px;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
font-size: 24px;
|
||||||
|
top: 10px;
|
||||||
|
right: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.overlay {
|
||||||
|
padding: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-top: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
padding: 24px 20px 16px 20px;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
font-size: 22px;
|
||||||
|
top: 8px;
|
||||||
|
right: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.overlay {
|
||||||
|
padding: 8px;
|
||||||
|
padding-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
padding: 20px 16px 12px 16px;
|
||||||
|
min-width: 260px;
|
||||||
|
max-width: 98vw;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
font-size: 20px;
|
||||||
|
top: 6px;
|
||||||
|
right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch устройств */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.closeBtn {
|
||||||
|
min-width: 48px;
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control {
|
.control {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -15,28 +16,34 @@
|
|||||||
transition: border 0.2s;
|
transition: border 0.2s;
|
||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control:focus, .control:active {
|
.control:focus, .control:active {
|
||||||
border-color: #6366f1;
|
border-color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.disabled {
|
.disabled {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
color: #b3b3b3;
|
color: #b3b3b3;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.placeholder {
|
.placeholder {
|
||||||
color: #9ca3af;
|
color: #9ca3af;
|
||||||
}
|
}
|
||||||
|
|
||||||
.arrow {
|
.arrow {
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #6366f1;
|
color: #6366f1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown {
|
.dropdown {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
@ -51,6 +58,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 6px 0;
|
padding: 6px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option {
|
.option {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@ -61,10 +69,96 @@
|
|||||||
color: #374151;
|
color: #374151;
|
||||||
transition: background 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.option:hover {
|
.option:hover {
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected {
|
.selected {
|
||||||
background: #e0e7ff;
|
background: #e0e7ff;
|
||||||
color: #3730a3;
|
color: #3730a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для планшетов */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.wrapper {
|
||||||
|
min-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
min-height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.wrapper {
|
||||||
|
min-width: 140px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 16px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
padding: 10px 16px;
|
||||||
|
font-size: 15px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
max-height: 250px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.wrapper {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control {
|
||||||
|
padding: 8px 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
max-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch устройств */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.control {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option {
|
||||||
|
min-height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown {
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -4,7 +4,9 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
border: none;
|
border: none;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
@ -17,7 +19,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.18s, color 0.18s;
|
transition: background 0.18s, color 0.18s;
|
||||||
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
|
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
border: none;
|
border: none;
|
||||||
background: #f3f4f6;
|
background: #f3f4f6;
|
||||||
@ -30,10 +36,76 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.18s, color 0.18s;
|
transition: background 0.18s, color 0.18s;
|
||||||
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
|
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageActive {
|
.pageActive {
|
||||||
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для планшетов */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.wrapper {
|
||||||
|
margin-top: 20px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn, .page {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Адаптивные стили для мобильных устройств */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.wrapper {
|
||||||
|
margin-top: 16px;
|
||||||
|
gap: 3px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn, .page {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.wrapper {
|
||||||
|
margin-top: 12px;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn, .page {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенная поддержка touch устройств */
|
||||||
|
@media (hover: none) and (pointer: coarse) {
|
||||||
|
.btn, .page {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user