mobile ui

This commit is contained in:
romantarkin 2025-08-02 16:19:00 +05:00
parent 175f2079a6
commit e793ef1de6
24 changed files with 1736 additions and 420 deletions

111
frontend/README.md Normal file
View 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/`

View File

@ -10,7 +10,7 @@
"react-scripts": "5.0.1",
"react-select": "^5.10.2"
},
"proxy": "http://project.ru",
"proxy": "http://project.ru:8081",
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",

View File

@ -1,17 +1,19 @@
<!DOCTYPE html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/logo.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Web site created using create-react-app" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no" />
<meta name="theme-color" content="#6366f1" />
<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="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>React App</title>
<title>CoreSync MRM</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<noscript>Для работы приложения необходимо включить JavaScript</noscript>
<div id="root"></div>
</body>
</html>

View File

@ -12,16 +12,19 @@
top: 0;
z-index: 10;
}
.right {
display: flex;
align-items: center;
gap: 18px;
}
.user {
font-size: 16px;
color: #6366f1;
font-weight: 600;
}
.logout {
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
color: #fff;
@ -33,6 +36,59 @@
cursor: pointer;
transition: background 0.2s;
}
.logout:hover {
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;
}
}

View File

@ -1,26 +1,59 @@
import React from 'react';
import React, { useState } from 'react';
import styles from './SideMenu.module.css';
const SideMenu = ({ active, onSelect }) => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const handleMenuSelect = (menuItem) => {
onSelect(menuItem);
setIsMobileMenuOpen(false);
};
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
return (
<aside className={styles.menu}>
<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={() => onSelect('smtp')}>SMTP-сервера</li>
<li className={active === 'template' ? styles.active : ''} onClick={() => onSelect('template')}>Шаблон письма</li>
<li className={active === 'unsubscribed' ? styles.active : ''} onClick={() => onSelect('unsubscribed')}>Отписались</li>
<li className={active === 'groups' ? styles.active : ''} onClick={() => onSelect('groups')}>Подписчики и группы</li>
<li className={active === 'history' ? styles.active : ''} onClick={() => onSelect('history')}>История отправок</li>
<li className={active === 'campaign' ? styles.active : ''} onClick={() => onSelect('campaign')}>Кампания</li>
</ul>
<div className={styles.section}>Администрирование</div>
<ul>
<li className={active === 'users' ? styles.active : ''} onClick={() => onSelect('users')}>Управление пользователями</li>
</ul>
</nav>
</aside>
<>
{/* Мобильная кнопка меню */}
<button
className={styles.mobileMenuButton}
onClick={toggleMobileMenu}
aria-label="Открыть меню"
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="6" x2="21" y2="6"></line>
<line x1="3" y1="12" x2="21" y2="12"></line>
<line x1="3" y1="18" x2="21" y2="18"></line>
</svg>
</button>
{/* Оверлей для мобильного меню */}
<div
className={`${styles.mobileOverlay} ${isMobileMenuOpen ? styles.open : ''}`}
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>
</>
);
};

View File

@ -6,7 +6,9 @@
display: flex;
flex-direction: column;
padding: 0 0 24px 0;
transition: transform 0.3s ease;
}
.project {
font-size: 22px;
font-weight: 700;
@ -16,10 +18,12 @@
border-bottom: 1px solid #e5e7eb;
margin-bottom: 12px;
}
.nav {
flex: 1;
padding: 0 0 0 0;
}
.section {
font-size: 15px;
color: #6366f1;
@ -28,11 +32,13 @@
text-transform: uppercase;
letter-spacing: 0.5px;
}
ul {
list-style: none;
margin: 0;
padding: 0 0 0 0;
}
li {
padding: 12px 24px;
font-size: 16px;
@ -41,13 +47,148 @@ li {
border-left: 3px solid transparent;
transition: background 0.15s, border 0.15s, color 0.15s;
}
li:hover {
background: #e0e7ff;
color: #3730a3;
}
.active {
background: #e0e7ff;
color: #3730a3;
border-left: 3px solid #6366f1;
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;
}
}

View File

@ -5,9 +5,53 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
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;
}
}

View File

@ -2,7 +2,7 @@ import React from 'react';
import Modal from './Modal';
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 (
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
<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} />
</label>
<label className={styles.label}>Роль
<select value={user.role_id} onChange={e => onChange({ ...user, role_id: Number(e.target.value) })} required className={styles.input}>
{roles.length === 0 ? (
<option value="">Загрузка ролей...</option>
) : (
roles.map(role => (
<option key={role.id} value={role.id}>{role.name}</option>
))
)}
<select value={user.role} onChange={e => onChange({ ...user, role: e.target.value })} required className={styles.input}>
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</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 className={styles.label}>Пароль
<input type="password" value={user.password} onChange={e => onChange({ ...user, password: e.target.value })} required className={styles.input} />

View File

@ -2,7 +2,7 @@ import React from 'react';
import Modal from './Modal';
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 (
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
<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} />
</label>
<label className={styles.label}>Роль
<select value={user.role_id} onChange={e => onChange({ ...user, role_id: Number(e.target.value) })} required className={styles.input}>
{roles.length === 0 ? (
<option value="">Загрузка ролей...</option>
) : (
roles.map(role => (
<option key={role.id} value={role.id}>{role.name}</option>
))
)}
<select value={user.role} onChange={e => onChange({ ...user, role: e.target.value })} required className={styles.input}>
<option value="user">Пользователь</option>
<option value="admin">Администратор</option>
</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>
<div className={styles.actions}>
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>

View File

@ -3,11 +3,12 @@ import { useUser } from '../context/UserContext';
import EditCampaignModal from '../modals/EditCampaignModal';
import CreateCampaignModal from '../modals/CreateCampaignModal';
import Paginator from '../components/Paginator';
import styles from '../styles/Common.module.css';
const PAGE_SIZE = 10;
function CampaignPage() {
const { token, user } = useUser();
const { token } = useUser();
const [campaigns, setCampaigns] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@ -98,14 +99,14 @@ function CampaignPage() {
const fetchSmtpServers = async () => {
try {
const res = await fetch('/api/mail/smtp-servers?limit=1000', {
const res = await fetch('/api/mail/smtp-servers', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (res.ok && Array.isArray(data)) {
setSmtpServers(data);
} else if (res.ok && Array.isArray(data.rows)) {
if (res.ok && Array.isArray(data.rows)) {
setSmtpServers(data.rows);
} else if (res.ok && Array.isArray(data)) {
setSmtpServers(data);
} else {
setSmtpServers([]);
}
@ -136,10 +137,7 @@ function CampaignPage() {
};
const handleEdit = (c) => {
setEditCampaign({
...c,
smtp_server_ids: c.SmtpServers ? c.SmtpServers.map(s => s.id) : [],
});
setEditCampaign(c);
};
const handleEditSave = async (e) => {
@ -152,7 +150,7 @@ function CampaignPage() {
'Content-Type': 'application/json',
...(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) {
const data = await res.json();
@ -169,14 +167,7 @@ function CampaignPage() {
};
const handleCreate = () => {
setCreateCampaign({
group_id: groups[0]?.id || '',
template_version_id: versions[0]?.id || '',
subject_override: '',
scheduled_at: '',
status: 'draft',
smtp_server_ids: [],
});
setCreateCampaign({ group_id: '', template_version_id: '', subject_override: '', status: 'draft', scheduled_at: '' });
};
const handleCreateSave = async (e) => {
@ -189,7 +180,7 @@ function CampaignPage() {
'Content-Type': 'application/json',
...(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) {
const data = await res.json();
@ -212,46 +203,71 @@ function CampaignPage() {
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить кампанию</button>
<div className={styles.container}>
<div className={styles.pageHeader}>
<h2 className={styles.pageTitle}>Кампания</h2>
<div className={styles.pageActions}>
<button
onClick={handleCreate}
className={`${styles.button} ${styles.buttonGradient}`}
>
+ Добавить кампанию
</button>
</div>
</div>
<h2>Кампания</h2>
{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 && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Группа</th>
<th style={thStyle}>Версия шаблона</th>
<th style={thStyle}>Тема</th>
<th style={thStyle}>Статус</th>
<th style={thStyle}>Запланировано</th>
<th style={thStyle}></th>
<th className={styles.tableHeaderCell}>ID</th>
<th className={styles.tableHeaderCell}>Группа</th>
<th className={styles.tableHeaderCell}>Версия шаблона</th>
<th className={styles.tableHeaderCell}>Тема</th>
<th className={styles.tableHeaderCell}>Статус</th>
<th className={styles.tableHeaderCell}>Запланировано</th>
<th className={styles.tableHeaderCell}>Действия</th>
</tr>
</thead>
<tbody>
{campaigns.map(c => (
<tr key={c.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{c.id}</td>
<td style={tdStyle}>{getGroupName(c.group_id)}</td>
<td style={tdStyle}>{getVersionName(c.template_version_id)}</td>
<td style={tdStyle}>{c.subject_override || ''}</td>
<td style={tdStyle}>{c.status}</td>
<td style={tdStyle}>{c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : ''}</td>
<td style={tdStyle}>
<button onClick={() => handleEdit(c)} style={btnStyle}>Редактировать</button>
<button onClick={() => handleDelete(c.id)} style={btnStyle} disabled={deleteLoading === c.id}>
<tr key={c.id} className={styles.tableRow}>
<td className={styles.tableCell}>{c.id}</td>
<td className={styles.tableCell}>{getGroupName(c.group_id)}</td>
<td className={styles.tableCell}>{getVersionName(c.template_version_id)}</td>
<td className={styles.tableCell}>{c.subject_override || ''}</td>
<td className={styles.tableCell}>{c.status}</td>
<td className={styles.tableCell}>{c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : ''}</td>
<td className={styles.tableCellActions}>
<button
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 ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{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>
</table>
@ -263,6 +279,7 @@ function CampaignPage() {
/>
</>
)}
{editCampaign && (
<EditCampaignModal
isOpen={!!editCampaign}
@ -277,6 +294,7 @@ function CampaignPage() {
getVersionName={getVersionName}
/>
)}
{createCampaign && (
<CreateCampaignModal
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;

View File

@ -9,6 +9,7 @@ import UnsubscribedPage from './UnsubscribedPage';
import GroupsPage from './GroupsPage';
import DeliveryHistoryPage from './DeliveryHistoryPage';
import CampaignPage from './CampaignPage';
import styles from './Dashboard.module.css';
const Dashboard = () => {
const [active, setActive] = useState('smtp');
@ -33,11 +34,11 @@ const Dashboard = () => {
}
return (
<div style={{ display: 'flex', minHeight: '100vh', background: '#f8fafc' }}>
<div className={styles.dashboard}>
<SideMenu active={active} onSelect={setActive} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div className={styles.content}>
<Header user={user} onLogout={handleLogout} />
<div style={{ flex: 1, padding: 32 }}>
<div className={styles.pageContent}>
{renderPage()}
</div>
</div>

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

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useUser } from '../context/UserContext';
import Paginator from '../components/Paginator';
import styles from '../styles/Common.module.css';
const PAGE_SIZE = 10;
@ -44,40 +45,50 @@ function DeliveryHistoryPage() {
};
return (
<div>
<h2>История отправок</h2>
{loading && <div>Загрузка...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div className={styles.container}>
<div className={styles.pageHeader}>
<h2 className={styles.pageTitle}>История отправок</h2>
</div>
{loading && <div className={styles.loading}>Загрузка...</div>}
{error && <div className={styles.error}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Статус</th>
<th style={thStyle}>Дата отправки</th>
<th style={thStyle}>Открыто</th>
<th style={thStyle}>Клик</th>
<th style={thStyle}>Ошибка</th>
<th style={thStyle}>Кампания</th>
<th className={styles.tableHeaderCell}>ID</th>
<th className={styles.tableHeaderCell}>Email</th>
<th className={styles.tableHeaderCell}>Статус</th>
<th className={styles.tableHeaderCell}>Дата отправки</th>
<th className={styles.tableHeaderCell}>Открыто</th>
<th className={styles.tableHeaderCell}>Клик</th>
<th className={styles.tableHeaderCell}>Ошибка</th>
<th className={styles.tableHeaderCell}>Кампания</th>
</tr>
</thead>
<tbody>
{logs.map(l => (
<tr key={l.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{l.id}</td>
<td style={tdStyle}>{l.Subscriber?.email || l.subscriber_id}</td>
<td style={tdStyle}>{l.status}</td>
<td style={tdStyle}>{l.sent_at ? new Date(l.sent_at).toLocaleString() : ''}</td>
<td style={tdStyle}>{l.opened_at ? new Date(l.opened_at).toLocaleString() : ''}</td>
<td style={tdStyle}>{l.clicked_at ? new Date(l.clicked_at).toLocaleString() : ''}</td>
<td style={tdStyle}>{l.error_message || ''}</td>
<td style={tdStyle}>{l.campaign_id}</td>
<tr key={l.id} className={styles.tableRow}>
<td className={styles.tableCell}>{l.id}</td>
<td className={styles.tableCell}>{l.Subscriber?.email || l.subscriber_id}</td>
<td className={styles.tableCell}>{l.status}</td>
<td className={styles.tableCell}>{l.sent_at ? new Date(l.sent_at).toLocaleString() : ''}</td>
<td className={styles.tableCell}>{l.opened_at ? new Date(l.opened_at).toLocaleString() : ''}</td>
<td className={styles.tableCell}>{l.clicked_at ? new Date(l.clicked_at).toLocaleString() : ''}</td>
<td className={styles.tableCell}>{l.error_message || ''}</td>
<td className={styles.tableCell}>{l.campaign_id}</td>
</tr>
))}
{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>
</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;

View File

@ -4,11 +4,12 @@ import EditTemplateModal from '../modals/EditTemplateModal';
import CreateTemplateModal from '../modals/CreateTemplateModal';
import TemplateVersionsPage from './TemplateVersionsPage';
import Paginator from '../components/Paginator';
import styles from '../styles/Common.module.css';
const PAGE_SIZE = 10;
function EmailTemplatesPage() {
const { token, user } = useUser();
const { token } = useUser();
const [templates, setTemplates] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
@ -36,7 +37,7 @@ function EmailTemplatesPage() {
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка загрузки шаблонов');
setError(data.error || 'Ошибка загрузки');
setTemplates([]);
setTotal(0);
} else {
@ -87,7 +88,7 @@ function EmailTemplatesPage() {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...editTemplate, user_id: user?.id })
body: JSON.stringify(editTemplate)
});
if (!res.ok) {
const data = await res.json();
@ -104,7 +105,7 @@ function EmailTemplatesPage() {
};
const handleCreate = () => {
setCreateTemplate({ name: '' });
setCreateTemplate({ name: '', description: '' });
};
const handleCreateSave = async (e) => {
@ -117,7 +118,7 @@ function EmailTemplatesPage() {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...createTemplate, user_id: user?.id })
body: JSON.stringify(createTemplate)
});
if (!res.ok) {
const data = await res.json();
@ -138,38 +139,65 @@ function EmailTemplatesPage() {
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить шаблон</button>
<div className={styles.container}>
<div className={styles.pageHeader}>
<h2 className={styles.pageTitle}>Шаблоны писем</h2>
<div className={styles.pageActions}>
<button
onClick={handleCreate}
className={`${styles.button} ${styles.buttonGradient}`}
>
+ Добавить шаблон
</button>
</div>
</div>
<h2>Шаблоны писем</h2>
{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 && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Название</th>
<th style={thStyle}></th>
<th className={styles.tableHeaderCell}>ID</th>
<th className={styles.tableHeaderCell}>Название</th>
<th className={styles.tableHeaderCell}>Описание</th>
<th className={styles.tableHeaderCell}>Действия</th>
</tr>
</thead>
<tbody>
{templates.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid #e5e7eb', cursor: 'pointer' }} onClick={() => setSelectedTemplate(t)}>
<td style={tdStyle}>{t.id}</td>
<td style={tdStyle}>{t.name}</td>
<td style={tdStyle}>
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={btnStyle}>Редактировать</button>
<button onClick={e => { e.stopPropagation(); handleDelete(t.id); }} style={btnStyle} disabled={deleteLoading === t.id}>
<tr key={t.id} className={styles.tableRow} style={{ cursor: 'pointer' }} onClick={() => setSelectedTemplate(t)}>
<td className={styles.tableCell}>{t.id}</td>
<td className={styles.tableCell}>{t.name}</td>
<td className={styles.tableCell}>{t.description}</td>
<td className={styles.tableCellActions}>
<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 ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{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>
</table>
@ -181,6 +209,7 @@ function EmailTemplatesPage() {
/>
</>
)}
{editTemplate && (
<EditTemplateModal
isOpen={!!editTemplate}
@ -191,6 +220,7 @@ function EmailTemplatesPage() {
onSave={handleEditSave}
/>
)}
{createTemplate && (
<CreateTemplateModal
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;

View File

@ -5,6 +5,7 @@ import CreateGroupModal from '../modals/CreateGroupModal';
import EditSubscriberModal from '../modals/EditSubscriberModal';
import CreateSubscriberModal from '../modals/CreateSubscriberModal';
import Paginator from '../components/Paginator';
import styles from '../styles/Common.module.css';
const PAGE_SIZE = 10;
@ -139,40 +140,65 @@ function GroupsPage() {
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить группу</button>
<div className={styles.container}>
<div className={styles.pageHeader}>
<h2 className={styles.pageTitle}>Подписчики и группы</h2>
<div className={styles.pageActions}>
<button
onClick={handleCreate}
className={`${styles.button} ${styles.buttonGradient}`}
>
+ Добавить группу
</button>
</div>
</div>
<h2>Группы подписчиков</h2>
{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 && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Название</th>
<th style={thStyle}>Описание</th>
<th style={thStyle}></th>
<th className={styles.tableHeaderCell}>ID</th>
<th className={styles.tableHeaderCell}>Название</th>
<th className={styles.tableHeaderCell}>Описание</th>
<th className={styles.tableHeaderCell}>Действия</th>
</tr>
</thead>
<tbody>
{groups.map(g => (
<tr key={g.id} style={{ borderBottom: '1px solid #e5e7eb', cursor: 'pointer' }} onClick={() => setSelectedGroup(g)}>
<td style={tdStyle}>{g.id}</td>
<td style={tdStyle}>{g.name}</td>
<td style={tdStyle}>{g.description}</td>
<td style={tdStyle}>
<button onClick={e => { e.stopPropagation(); handleEdit(g); }} style={btnStyle}>Редактировать</button>
<button onClick={e => { e.stopPropagation(); handleDelete(g.id); }} style={btnStyle} disabled={deleteLoading === g.id}>
<tr key={g.id} className={styles.tableRow} style={{ cursor: 'pointer' }} onClick={() => setSelectedGroup(g)}>
<td className={styles.tableCell}>{g.id}</td>
<td className={styles.tableCell}>{g.name}</td>
<td className={styles.tableCell}>{g.description}</td>
<td className={styles.tableCellActions}>
<button
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 ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{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>
</table>
@ -184,6 +210,7 @@ function GroupsPage() {
/>
</>
)}
{editGroup && (
<EditGroupModal
isOpen={!!editGroup}
@ -194,6 +221,7 @@ function GroupsPage() {
onSave={handleEditSave}
/>
)}
{createGroup && (
<CreateGroupModal
isOpen={!!createGroup}
@ -349,43 +377,75 @@ function SubscribersInGroupPage({ group, onBack, token }) {
};
return (
<div>
<button onClick={onBack} style={{ marginBottom: 18, background: 'none', border: 'none', color: '#6366f1', fontWeight: 600, fontSize: 16, cursor: 'pointer' }}> Назад к группам</button>
<h2>Подписчики группы: {group.name}</h2>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить подписчика</button>
<div className={styles.container}>
<button
onClick={onBack}
className={`${styles.button} ${styles.buttonSecondary}`}
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>
{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 && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Имя</th>
<th style={thStyle}>Статус</th>
<th style={thStyle}></th>
<th className={styles.tableHeaderCell}>ID</th>
<th className={styles.tableHeaderCell}>Email</th>
<th className={styles.tableHeaderCell}>Имя</th>
<th className={styles.tableHeaderCell}>Статус</th>
<th className={styles.tableHeaderCell}>Действия</th>
</tr>
</thead>
<tbody>
{subs.map(gs => (
<tr key={gs.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{gs.Subscriber?.id || ''}</td>
<td style={tdStyle}>{gs.Subscriber?.email || ''}</td>
<td style={tdStyle}>{gs.Subscriber?.name || ''}</td>
<td style={tdStyle}>{gs.Subscriber?.status || ''}</td>
<td style={tdStyle}>
<button onClick={() => handleEdit(gs)} style={btnStyle}>Редактировать</button>
<button onClick={() => handleDelete(gs.id)} style={btnStyle} disabled={deleteLoading === gs.id}>
<tr key={gs.id} className={styles.tableRow}>
<td className={styles.tableCell}>{gs.Subscriber?.id || ''}</td>
<td className={styles.tableCell}>{gs.Subscriber?.email || ''}</td>
<td className={styles.tableCell}>{gs.Subscriber?.name || ''}</td>
<td className={styles.tableCell}>{gs.Subscriber?.status || ''}</td>
<td className={styles.tableCellActions}>
<button
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 ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{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>
</table>
@ -397,6 +457,7 @@ function SubscribersInGroupPage({ group, onBack, token }) {
/>
</>
)}
{editSub && (
<EditSubscriberModal
isOpen={!!editSub}
@ -407,6 +468,7 @@ function SubscribersInGroupPage({ group, onBack, token }) {
onSave={handleEditSave}
/>
)}
{createSub && (
<CreateSubscriberModal
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;

View File

@ -4,12 +4,14 @@
justify-content: center;
align-items: center;
background: linear-gradient(135deg, #e0e7ff 0%, #f0fdfa 100%);
padding: 16px;
}
.form {
display: flex;
flex-direction: column;
width: 340px;
max-width: 100%;
gap: 20px;
padding: 36px;
border-radius: 16px;
@ -37,9 +39,12 @@
outline: none;
transition: border 0.2s;
background: #f8fafc;
min-height: 48px;
}
.input:focus {
border: 1.5px solid #6366f1;
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.button {
@ -53,6 +58,16 @@
cursor: pointer;
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
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 {
@ -60,4 +75,94 @@
text-align: center;
font-size: 15px;
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;
}
}

View File

@ -3,6 +3,7 @@ import { useUser } from '../context/UserContext';
import EditSmtpModal from '../modals/EditSmtpModal';
import CreateSmtpModal from '../modals/CreateSmtpModal';
import Paginator from '../components/Paginator';
import styles from '../styles/Common.module.css';
const PAGE_SIZE = 10;
@ -117,9 +118,7 @@ function SmtpServersPage() {
};
const handleCreate = () => {
setCreateServer({
name: '', host: '', port: 587, secure: false, username: '', password: '', from_email: ''
});
setCreateServer({ name: '', host: '', port: 587, secure: false, username: '', password: '', from_email: '', from_name: '' });
};
const handleCreateSave = async (e) => {
@ -130,14 +129,13 @@ function SmtpServersPage() {
}
setCreateLoading(true);
try {
const { group_id, ...serverData } = createServer;
const res = await fetch('/api/mail/smtp-servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`
},
body: JSON.stringify({ ...serverData, user_id: user?.id })
body: JSON.stringify({ ...createServer, user_id: user?.id })
});
if (!res.ok) {
const data = await res.json();
@ -154,48 +152,73 @@ function SmtpServersPage() {
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить SMTP-сервер</button>
<div className={styles.container}>
<div className={styles.pageHeader}>
<h2 className={styles.pageTitle}>SMTP-серверы</h2>
<div className={styles.pageActions}>
<button
onClick={handleCreate}
className={`${styles.button} ${styles.buttonGradient}`}
>
+ Добавить SMTP-сервер
</button>
</div>
</div>
<h2>SMTP-серверы</h2>
{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 && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Название</th>
<th style={thStyle}>Host</th>
<th style={thStyle}>Port</th>
<th style={thStyle}>Secure</th>
<th style={thStyle}>Пользователь</th>
<th style={thStyle}>Отправитель</th>
<th style={thStyle}></th>
<th className={styles.tableHeaderCell}>ID</th>
<th className={styles.tableHeaderCell}>Название</th>
<th className={styles.tableHeaderCell}>Host</th>
<th className={styles.tableHeaderCell}>Port</th>
<th className={styles.tableHeaderCell}>Secure</th>
<th className={styles.tableHeaderCell}>Пользователь</th>
<th className={styles.tableHeaderCell}>Отправитель</th>
<th className={styles.tableHeaderCell}>Действия</th>
</tr>
</thead>
<tbody>
{servers.map(s => (
<tr key={s.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{s.id}</td>
<td style={tdStyle}>{s.name}</td>
<td style={tdStyle}>{s.host}</td>
<td style={tdStyle}>{s.port}</td>
<td style={tdStyle}>{s.secure ? 'Да' : 'Нет'}</td>
<td style={tdStyle}>{s.username}</td>
<td style={tdStyle}>{s.from_email}</td>
<td style={tdStyle}>
<button onClick={() => handleEdit(s)} style={btnStyle}>Редактировать</button>
<button onClick={() => handleDelete(s.id)} style={btnStyle} disabled={deleteLoading === s.id}>
<tr key={s.id} className={styles.tableRow}>
<td className={styles.tableCell}>{s.id}</td>
<td className={styles.tableCell}>{s.name}</td>
<td className={styles.tableCell}>{s.host}</td>
<td className={styles.tableCell}>{s.port}</td>
<td className={styles.tableCell}>{s.secure ? 'Да' : 'Нет'}</td>
<td className={styles.tableCell}>{s.username}</td>
<td className={styles.tableCell}>{s.from_email}</td>
<td className={styles.tableCellActions}>
<button
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 ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{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>
</table>
@ -207,6 +230,7 @@ function SmtpServersPage() {
/>
</>
)}
{editServer && (
<EditSmtpModal
isOpen={!!editServer}
@ -217,6 +241,7 @@ function SmtpServersPage() {
onSave={handleEditSave}
/>
)}
{createServer && (
<CreateSmtpModal
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;

View File

@ -1,5 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import CreateTemplateVersionModal from '../modals/CreateTemplateVersionModal';
import Paginator from '../components/Paginator';
import styles from '../styles/Common.module.css';
const PAGE_SIZE = 10;
@ -128,58 +130,102 @@ export default function TemplateVersionsPage({ template, onBack, token }) {
};
return (
<div>
<button onClick={onBack} style={{ marginBottom: 18, background: 'none', border: 'none', color: '#6366f1', fontWeight: 600, fontSize: 16, cursor: 'pointer' }}> Назад к шаблонам</button>
<h2>Версии шаблона: {template.name}</h2>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleAddVersion} style={addBtnStyle}>+ Добавить версию</button>
<div className={styles.container}>
<button
onClick={onBack}
className={`${styles.button} ${styles.buttonSecondary}`}
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>
{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 && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Версия</th>
<th style={thStyle}>Тема</th>
<th style={thStyle}>HTML</th>
<th style={thStyle}>Текст</th>
<th style={thStyle}></th>
<th className={styles.tableHeaderCell}>ID</th>
<th className={styles.tableHeaderCell}>Версия</th>
<th className={styles.tableHeaderCell}>Тема</th>
<th className={styles.tableHeaderCell}>HTML</th>
<th className={styles.tableHeaderCell}>Текст</th>
<th className={styles.tableHeaderCell}>Действия</th>
</tr>
</thead>
<tbody>
{versions.map(v => (
<tr key={v.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{v.id}</td>
<td style={tdStyle}>{v.version_number}</td>
<td style={tdStyle}>{v.subject}</td>
<td style={tdStyle}>{v.body_html ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
<td style={tdStyle}>{v.body_text ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
<td style={tdStyle}>
<button onClick={() => handleEditVersion(v)} style={btnStyle}>Редактировать</button>
<button onClick={() => handleDeleteVersion(v.id)} style={btnStyle} disabled={deleteLoading === v.id}>
<tr key={v.id} className={styles.tableRow}>
<td className={styles.tableCell}>{v.id}</td>
<td className={styles.tableCell}>{v.version_number}</td>
<td className={styles.tableCell}>{v.subject}</td>
<td className={styles.tableCell}>
{v.body_html ?
<span style={{ color: '#10b981' }}>Есть</span> :
<span style={{ color: '#ef4444' }}>Нет</span>
}
</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 ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{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>
</table>
{/* Пагинация если нужно */}
{total > PAGE_SIZE && (
<div style={{ marginTop: 16 }}>
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} style={btnStyle}>Назад</button>
<span style={{ margin: '0 12px' }}>Страница {page}</span>
<button onClick={() => setPage(p => p + 1)} disabled={page * PAGE_SIZE >= total} style={btnStyle}>Вперёд</button>
</div>
<Paginator
page={page}
total={total}
pageSize={PAGE_SIZE}
onPageChange={setPage}
/>
)}
</>
)}
{createVersionModal && (
<CreateTemplateVersionModal
isOpen={createVersionModal}
@ -188,6 +234,7 @@ export default function TemplateVersionsPage({ template, onBack, token }) {
onSave={handleCreateVersionSave}
/>
)}
{editVersion && (
<CreateTemplateVersionModal
isOpen={!!editVersion}
@ -199,9 +246,4 @@ export default function TemplateVersionsPage({ template, onBack, token }) {
)}
</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' };
}

View File

@ -3,6 +3,7 @@ import { useUser } from '../context/UserContext';
import EditUnsubModal from '../modals/EditUnsubModal';
import CreateUnsubModal from '../modals/CreateUnsubModal';
import Paginator from '../components/Paginator';
import styles from '../styles/Common.module.css';
const PAGE_SIZE = 10;
@ -85,7 +86,7 @@ function UnsubscribedPage() {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...editSub, status: 'unsubscribed' })
body: JSON.stringify(editSub)
});
if (!res.ok) {
const data = await res.json();
@ -102,7 +103,7 @@ function UnsubscribedPage() {
};
const handleCreate = () => {
setCreateSub({ email: '', name: '', status: 'unsubscribed' });
setCreateSub({ email: '', name: '' });
};
const handleCreateSave = async (e) => {
@ -132,42 +133,67 @@ function UnsubscribedPage() {
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить запись</button>
<div className={styles.container}>
<div className={styles.pageHeader}>
<h2 className={styles.pageTitle}>Отписались</h2>
<div className={styles.pageActions}>
<button
onClick={handleCreate}
className={`${styles.button} ${styles.buttonGradient}`}
>
+ Добавить запись
</button>
</div>
</div>
<h2>Отписались</h2>
{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 && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Имя</th>
<th style={thStyle}>Дата отписки</th>
<th style={thStyle}></th>
<th className={styles.tableHeaderCell}>ID</th>
<th className={styles.tableHeaderCell}>Email</th>
<th className={styles.tableHeaderCell}>Имя</th>
<th className={styles.tableHeaderCell}>Дата отписки</th>
<th className={styles.tableHeaderCell}>Действия</th>
</tr>
</thead>
<tbody>
{subs.map(s => (
<tr key={s.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{s.id}</td>
<td style={tdStyle}>{s.email}</td>
<td style={tdStyle}>{s.name}</td>
<td style={tdStyle}>{s.unsubscribed_at ? new Date(s.unsubscribed_at).toLocaleString() : ''}</td>
<td style={tdStyle}>
<button onClick={() => handleEdit(s)} style={btnStyle}>Редактировать</button>
<button onClick={() => handleDelete(s.id)} style={btnStyle} disabled={deleteLoading === s.id}>
<tr key={s.id} className={styles.tableRow}>
<td className={styles.tableCell}>{s.id}</td>
<td className={styles.tableCell}>{s.email}</td>
<td className={styles.tableCell}>{s.name}</td>
<td className={styles.tableCell}>{s.unsubscribed_at ? new Date(s.unsubscribed_at).toLocaleString() : ''}</td>
<td className={styles.tableCellActions}>
<button
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 ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{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>
</table>
@ -179,6 +205,7 @@ function UnsubscribedPage() {
/>
</>
)}
{editSub && (
<EditUnsubModal
isOpen={!!editSub}
@ -189,6 +216,7 @@ function UnsubscribedPage() {
onSave={handleEditSave}
/>
)}
{createSub && (
<CreateUnsubModal
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;

View File

@ -3,36 +3,31 @@ import { useUser } from '../context/UserContext';
import EditUserModal from '../modals/EditUserModal';
import CreateUserModal from '../modals/CreateUserModal';
import Paginator from '../components/Paginator';
import styles from '../styles/Common.module.css';
const PAGE_SIZE = 10;
function UsersPage() {
const { token } = useUser();
const [users, setUsers] = useState([]);
const [usersTotal, setUsersTotal] = useState(0);
const [usersPage, setUsersPage] = useState(1);
const [usersLoading, setUsersLoading] = useState(false);
const [usersError, setUsersError] = useState('');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editUser, setEditUser] = useState(null);
const [editLoading, setEditLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(null);
const [createUser, setCreateUser] = useState(null);
const [createLoading, setCreateLoading] = useState(false);
const [roles, setRoles] = useState([]);
useEffect(() => {
fetchUsers(usersPage);
fetchUsers(page);
// eslint-disable-next-line
}, [usersPage]);
useEffect(() => {
fetchRoles();
// eslint-disable-next-line
}, []);
}, [page]);
const fetchUsers = async (page) => {
setUsersLoading(true);
setUsersError('');
setLoading(true);
setError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/auth/users?limit=${PAGE_SIZE}&offset=${offset}`, {
@ -40,41 +35,19 @@ function UsersPage() {
});
const data = await res.json();
if (!res.ok) {
setUsersError(data.error || 'Ошибка загрузки пользователей');
setError(data.error || 'Ошибка загрузки');
setUsers([]);
setUsersTotal(0);
setTotal(0);
} else {
setUsers(Array.isArray(data) ? data : data.rows || []);
setUsersTotal(data.count || (Array.isArray(data) ? data.length : 0));
setUsers(Array.isArray(data.rows) ? data.rows : []);
setTotal(data.count || 0);
}
} catch (e) {
setUsersError('Ошибка сети');
setError('Ошибка сети');
setUsers([]);
setUsersTotal(0);
setTotal(0);
} finally {
setUsersLoading(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([]);
setLoading(false);
}
};
@ -90,7 +63,7 @@ function UsersPage() {
const data = await res.json();
alert(data.error || 'Ошибка удаления');
} else {
fetchUsers(usersPage);
fetchUsers(page);
}
} catch (e) {
alert('Ошибка сети');
@ -113,18 +86,14 @@ function UsersPage() {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({
email: editUser.email,
name: editUser.name,
role_id: editUser.role_id
})
body: JSON.stringify(editUser)
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка обновления');
} else {
setEditUser(null);
fetchUsers(usersPage);
fetchUsers(page);
}
} catch (e) {
alert('Ошибка сети');
@ -134,12 +103,7 @@ function UsersPage() {
};
const handleCreate = () => {
// Убеждаемся, что у нас есть роли перед созданием пользователя
if (roles.length === 0) {
alert('Загрузка ролей... Пожалуйста, подождите.');
return;
}
setCreateUser({ email: '', name: '', role_id: roles[0]?.id || 1, password: '' });
setCreateUser({ email: '', password: '', name: '', role: 'user' });
};
const handleCreateSave = async (e) => {
@ -159,7 +123,7 @@ function UsersPage() {
alert(data.error || 'Ошибка создания');
} else {
setCreateUser(null);
fetchUsers(usersPage);
fetchUsers(page);
}
} catch (e) {
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 (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить пользователя</button>
<div className={styles.container}>
<div className={styles.pageHeader}>
<h2 className={styles.pageTitle}>Управление пользователями</h2>
<div className={styles.pageActions}>
<button
onClick={handleCreate}
className={`${styles.button} ${styles.buttonGradient}`}
>
+ Добавить пользователя
</button>
</div>
</div>
<h2>Пользователи</h2>
{usersLoading && <div>Загрузка...</div>}
{usersError && <div style={{ color: 'red' }}>{usersError}</div>}
{!usersLoading && !usersError && (
{loading && <div className={styles.loading}>Загрузка...</div>}
{error && <div className={styles.error}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<table className={styles.table}>
<thead className={styles.tableHeader}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Имя</th>
<th style={thStyle}>Роль</th>
<th style={thStyle}></th>
<th className={styles.tableHeaderCell}>ID</th>
<th className={styles.tableHeaderCell}>Email</th>
<th className={styles.tableHeaderCell}>Имя</th>
<th className={styles.tableHeaderCell}>Роль</th>
<th className={styles.tableHeaderCell}>Действия</th>
</tr>
</thead>
<tbody>
{users.map(u => (
<tr key={u.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{u.id}</td>
<td style={tdStyle}>{u.email}</td>
<td style={tdStyle}>{u.name}</td>
<td style={tdStyle}>{getRoleName(u.role_id)}</td>
<td style={tdStyle}>
<button onClick={() => handleEdit(u)} style={btnStyle}>Редактировать</button>
<button onClick={() => handleDelete(u.id)} style={btnStyle} disabled={deleteLoading === u.id}>
<tr key={u.id} className={styles.tableRow}>
<td className={styles.tableCell}>{u.id}</td>
<td className={styles.tableCell}>{u.email}</td>
<td className={styles.tableCell}>{u.name}</td>
<td className={styles.tableCell}>{u.role}</td>
<td className={styles.tableCellActions}>
<button
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 ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{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>
</table>
<Paginator
page={usersPage}
total={usersTotal}
page={page}
total={total}
pageSize={PAGE_SIZE}
onPageChange={setUsersPage}
onPageChange={setPage}
/>
</>
)}
{editUser && (
<EditUserModal
isOpen={!!editUser}
onClose={() => setEditUser(null)}
user={editUser}
roles={roles}
loading={editLoading}
onChange={setEditUser}
onSave={handleEditSave}
/>
)}
{createUser && (
<CreateUserModal
isOpen={!!createUser}
onClose={() => setCreateUser(null)}
user={createUser}
roles={roles}
loading={createLoading}
onChange={setCreateUser}
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>
);
}
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;

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

View File

@ -9,6 +9,7 @@
align-items: center;
justify-content: center;
z-index: 1000;
padding: 16px;
}
.modal {
@ -16,9 +17,12 @@
border-radius: 14px;
padding: 32px 28px 24px 28px;
min-width: 340px;
max-width: 90vw;
max-height: 90vh;
box-shadow: 0 12px 48px 0 rgba(31,38,135,0.22);
position: relative;
animation: modalIn 0.18s cubic-bezier(.4,1.3,.6,1);
overflow-y: auto;
}
.closeBtn {
@ -36,9 +40,87 @@
z-index: 2;
opacity: 0.8;
transition: opacity 0.2s;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.closeBtn:hover {
opacity: 1;
}
@keyframes modalIn {
from { transform: translateY(40px) scale(0.98); opacity: 0; }
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;
}
}

View File

@ -2,6 +2,7 @@
position: relative;
min-width: 180px;
}
.control {
display: flex;
align-items: center;
@ -15,28 +16,34 @@
transition: border 0.2s;
min-height: 40px;
}
.control:focus, .control:active {
border-color: #6366f1;
}
.disabled {
background: #f3f4f6;
color: #b3b3b3;
cursor: not-allowed;
}
.value {
flex: 1;
white-space: pre-wrap;
overflow: hidden;
text-overflow: ellipsis;
}
.placeholder {
color: #9ca3af;
}
.arrow {
margin-left: 8px;
font-size: 14px;
color: #6366f1;
}
.dropdown {
position: absolute;
left: 0;
@ -51,6 +58,7 @@
overflow-y: auto;
padding: 6px 0;
}
.option {
padding: 8px 16px;
cursor: pointer;
@ -61,10 +69,96 @@
color: #374151;
transition: background 0.15s;
}
.option:hover {
background: #f3f4f6;
}
.selected {
background: #e0e7ff;
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;
}
}

View File

@ -4,7 +4,9 @@
gap: 6px;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
}
.btn {
border: none;
background: #f3f4f6;
@ -17,7 +19,11 @@
cursor: pointer;
transition: background 0.18s, color 0.18s;
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
display: flex;
align-items: center;
justify-content: center;
}
.page {
border: none;
background: #f3f4f6;
@ -30,10 +36,76 @@
cursor: pointer;
transition: background 0.18s, color 0.18s;
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
display: flex;
align-items: center;
justify-content: center;
}
.pageActive {
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
color: #fff;
cursor: default;
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;
}
}