This commit is contained in:
romantarkin 2025-07-23 13:57:53 +05:00
parent 814e3a25e6
commit bee42c5b16
20 changed files with 2283 additions and 42 deletions

View File

@ -17,8 +17,15 @@ export default {
}, },
async getAll(req, res) { async getAll(req, res) {
try { try {
const users = await User.findAll({ include: Role }); const limit = parseInt(req.query.limit) || 20;
res.json(users); const offset = parseInt(req.query.offset) || 0;
const result = await User.findAndCountAll({
include: Role,
limit,
offset,
order: [['id', 'ASC']]
});
res.json({ count: result.count, rows: result.rows });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }

View File

@ -9,7 +9,7 @@ const root = ReactDOM.createRoot(document.getElementById('root'));
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<UserProvider> <UserProvider>
<App /> <App />
</UserProvider> </UserProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@ -0,0 +1,372 @@
import React, { useState, useEffect } from 'react';
import { useUser } from '../context/UserContext';
const PAGE_SIZE = 10;
function CampaignPage() {
const { token, user } = useUser();
const [campaigns, setCampaigns] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editCampaign, setEditCampaign] = useState(null);
const [editLoading, setEditLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(null);
const [createCampaign, setCreateCampaign] = useState(null);
const [createLoading, setCreateLoading] = useState(false);
const [groups, setGroups] = useState([]);
const [versions, setVersions] = useState([]);
useEffect(() => {
fetchCampaigns(page);
// eslint-disable-next-line
}, [page]);
useEffect(() => {
fetchGroups();
fetchVersions();
// eslint-disable-next-line
}, []);
const fetchCampaigns = async (page) => {
setLoading(true);
setError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/mail/campaigns?limit=${PAGE_SIZE}&offset=${offset}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка загрузки');
setCampaigns([]);
setTotal(0);
} else {
setCampaigns(Array.isArray(data.rows) ? data.rows : []);
setTotal(data.count || 0);
}
} catch (e) {
setError('Ошибка сети');
setCampaigns([]);
setTotal(0);
} finally {
setLoading(false);
}
};
const fetchGroups = async () => {
try {
const res = await fetch('/api/mail/mailing-groups', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (res.ok && Array.isArray(data.rows)) {
setGroups(data.rows);
} else if (res.ok && Array.isArray(data)) {
setGroups(data);
} else {
setGroups([]);
}
} catch {
setGroups([]);
}
};
const fetchVersions = async () => {
try {
const res = await fetch('/api/mail/email-template-versions', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (res.ok && Array.isArray(data.rows)) {
setVersions(data.rows);
} else if (res.ok && Array.isArray(data)) {
setVersions(data);
} else {
setVersions([]);
}
} catch {
setVersions([]);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Удалить кампанию?')) return;
setDeleteLoading(id);
try {
const res = await fetch(`/api/mail/campaigns/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка удаления');
} else {
fetchCampaigns(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setDeleteLoading(null);
}
};
const handleEdit = (c) => {
setEditCampaign(c);
};
const handleEditSave = async (e) => {
e.preventDefault();
setEditLoading(true);
try {
const res = await fetch(`/api/mail/campaigns/${editCampaign.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...editCampaign, user_id: user?.id })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка обновления');
} else {
setEditCampaign(null);
fetchCampaigns(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setEditLoading(false);
}
};
const handleCreate = () => {
setCreateCampaign({
group_id: groups[0]?.id || '',
template_version_id: versions[0]?.id || '',
subject_override: '',
scheduled_at: '',
status: 'draft',
});
};
const handleCreateSave = async (e) => {
e.preventDefault();
setCreateLoading(true);
try {
const res = await fetch('/api/mail/campaigns', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...createCampaign, user_id: user?.id })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка создания');
} else {
setCreateCampaign(null);
fetchCampaigns(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setCreateLoading(false);
}
};
const getGroupName = (id) => groups.find(g => g.id === id)?.name || id;
const getVersionName = (id) => {
const v = versions.find(v => v.id === id);
return v ? `#${v.id} ${v.subject}` : id;
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить кампанию</button>
</div>
<h2>Кампания</h2>
{loading && <div>Загрузка...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<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>
</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}>
{deleteLoading === c.id ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{campaigns.length === 0 && (
<tr><td colSpan={7} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
)}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={paginatorWrapperStyle}>
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}></button>
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
style={{
...paginatorPageStyle,
...(page === i + 1 ? paginatorPageActiveStyle : {})
}}
disabled={page === i + 1}
>
{i + 1}
</button>
))}
<button onClick={() => setPage(page + 1)} disabled={page >= Math.ceil(total / PAGE_SIZE)} style={{ ...paginatorBtnStyle, opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}></button>
</div>
</div>
</>
)}
{editCampaign && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setEditCampaign(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={editLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Редактировать кампанию</h3>
<form onSubmit={handleEditSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Группа
<select value={editCampaign.group_id} onChange={e => setEditCampaign({ ...editCampaign, group_id: Number(e.target.value) })} required style={inputStyle}>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</label>
<label style={labelStyle}>Версия шаблона
<select value={editCampaign.template_version_id} onChange={e => setEditCampaign({ ...editCampaign, template_version_id: Number(e.target.value) })} required style={inputStyle}>
{versions.map(v => (
<option key={v.id} value={v.id}>{getVersionName(v.id)}</option>
))}
</select>
</label>
<label style={labelStyle}>Тема
<input type="text" value={editCampaign.subject_override || ''} onChange={e => setEditCampaign({ ...editCampaign, subject_override: e.target.value })} style={inputStyle} />
</label>
<label style={labelStyle}>Статус
<select value={editCampaign.status} onChange={e => setEditCampaign({ ...editCampaign, status: e.target.value })} required style={inputStyle}>
<option value="draft">Черновик</option>
<option value="scheduled">Запланировано</option>
<option value="sent">Отправлено</option>
<option value="failed">Ошибка</option>
</select>
</label>
<label style={labelStyle}>Запланировано на
<input type="datetime-local" value={editCampaign.scheduled_at ? new Date(editCampaign.scheduled_at).toISOString().slice(0,16) : ''} onChange={e => setEditCampaign({ ...editCampaign, scheduled_at: e.target.value })} style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={editLoading} style={saveBtnStyle}>{editLoading ? 'Сохранение...' : 'Сохранить'}</button>
<button type="button" onClick={() => setEditCampaign(null)} disabled={editLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</div>
</div>
)}
{createCampaign && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setCreateCampaign(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={createLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Добавить кампанию</h3>
<form onSubmit={handleCreateSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Группа
<select value={createCampaign.group_id} onChange={e => setCreateCampaign({ ...createCampaign, group_id: Number(e.target.value) })} required style={inputStyle}>
{groups.map(g => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</label>
<label style={labelStyle}>Версия шаблона
<select value={createCampaign.template_version_id} onChange={e => setCreateCampaign({ ...createCampaign, template_version_id: Number(e.target.value) })} required style={inputStyle}>
{versions.map(v => (
<option key={v.id} value={v.id}>{getVersionName(v.id)}</option>
))}
</select>
</label>
<label style={labelStyle}>Тема
<input type="text" value={createCampaign.subject_override || ''} onChange={e => setCreateCampaign({ ...createCampaign, subject_override: e.target.value })} style={inputStyle} />
</label>
<label style={labelStyle}>Статус
<select value={createCampaign.status} onChange={e => setCreateCampaign({ ...createCampaign, status: e.target.value })} required style={inputStyle}>
<option value="draft">Черновик</option>
<option value="scheduled">Запланировано</option>
<option value="sent">Отправлено</option>
<option value="failed">Ошибка</option>
</select>
</label>
<label style={labelStyle}>Запланировано на
<input type="datetime-local" value={createCampaign.scheduled_at} onChange={e => setCreateCampaign({ ...createCampaign, scheduled_at: e.target.value })} style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={createLoading} style={saveBtnStyle}>{createLoading ? 'Создание...' : 'Создать'}</button>
<button type="button" onClick={() => setCreateCampaign(null)} disabled={createLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</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 modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { 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' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
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' };
const paginatorWrapperStyle = { marginTop: 24, display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'flex-end' };
const paginatorBtnStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 18, fontWeight: 700, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageActiveStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', cursor: 'default', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)' };
export default CampaignPage;

View File

@ -2,6 +2,13 @@ import React, { useState } from 'react';
import SideMenu from '../components/SideMenu'; import SideMenu from '../components/SideMenu';
import Header from '../components/Header'; import Header from '../components/Header';
import { useUser } from '../context/UserContext'; import { useUser } from '../context/UserContext';
import UsersPage from './UsersPage';
import SmtpServersPage from './SmtpServersPage';
import EmailTemplatesPage from './EmailTemplatesPage';
import UnsubscribedPage from './UnsubscribedPage';
import GroupsPage from './GroupsPage';
import DeliveryHistoryPage from './DeliveryHistoryPage';
import CampaignPage from './CampaignPage';
const Dashboard = () => { const Dashboard = () => {
const [active, setActive] = useState('smtp'); const [active, setActive] = useState('smtp');
@ -12,34 +19,30 @@ const Dashboard = () => {
window.location.href = '/login'; window.location.href = '/login';
}; };
function renderPage() {
switch (active) {
case 'users': return <UsersPage />;
case 'smtp': return <SmtpServersPage />;
case 'template': return <EmailTemplatesPage />;
case 'unsubscribed': return <UnsubscribedPage />;
case 'groups': return <GroupsPage />;
case 'history': return <DeliveryHistoryPage />;
case 'campaign': return <CampaignPage />;
default: return null;
}
}
return ( return (
<div style={{ display: 'flex', minHeight: '100vh', background: '#f8fafc' }}> <div style={{ display: 'flex', minHeight: '100vh', background: '#f8fafc' }}>
<SideMenu active={active} onSelect={setActive} /> <SideMenu active={active} onSelect={setActive} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}> <div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<Header user={user} onLogout={handleLogout} /> <Header user={user} onLogout={handleLogout} />
<div style={{ flex: 1, padding: 32 }}> <div style={{ flex: 1, padding: 32 }}>
{/* Здесь будет контент выбранного раздела */} {renderPage()}
<h2>Раздел: {getSectionTitle(active)}</h2>
<div style={{ marginTop: 16, color: '#6b7280' }}>
Здесь будет содержимое для "{getSectionTitle(active)}".
</div>
</div> </div>
</div> </div>
</div> </div>
); );
}; };
function getSectionTitle(key) {
switch (key) {
case 'smtp': return 'SMTP-сервера';
case 'template': return 'Шаблон письма';
case 'unsubscribed': return 'Отписались';
case 'groups': return 'Подписчики и группы';
case 'history': return 'История отправок';
case 'campaign': return 'Кампания';
case 'users': return 'Управление пользователями';
default: return '';
}
}
export default Dashboard; export default Dashboard;

View File

@ -0,0 +1,115 @@
import React, { useState, useEffect } from 'react';
import { useUser } from '../context/UserContext';
const PAGE_SIZE = 10;
function DeliveryHistoryPage() {
const { token } = useUser();
const [logs, setLogs] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
fetchLogs(page);
// eslint-disable-next-line
}, [page]);
const fetchLogs = async (page) => {
setLoading(true);
setError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/mail/delivery-logs?limit=${PAGE_SIZE}&offset=${offset}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка загрузки');
setLogs([]);
setTotal(0);
} else {
setLogs(Array.isArray(data.rows) ? data.rows : []);
setTotal(data.count || 0);
}
} catch (e) {
setError('Ошибка сети');
setLogs([]);
setTotal(0);
} finally {
setLoading(false);
}
};
return (
<div>
<h2>История отправок</h2>
{loading && <div>Загрузка...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<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>
</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>
))}
{logs.length === 0 && (
<tr><td colSpan={8} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
)}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={paginatorWrapperStyle}>
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}></button>
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
style={{
...paginatorPageStyle,
...(page === i + 1 ? paginatorPageActiveStyle : {})
}}
disabled={page === i + 1}
>
{i + 1}
</button>
))}
<button onClick={() => setPage(page + 1)} disabled={page >= Math.ceil(total / PAGE_SIZE)} style={{ ...paginatorBtnStyle, opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}></button>
</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 paginatorWrapperStyle = { marginTop: 24, display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'flex-end' };
const paginatorBtnStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 18, fontWeight: 700, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageActiveStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', cursor: 'default', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)' };
export default DeliveryHistoryPage;

View File

@ -0,0 +1,258 @@
import React, { useState, useEffect } from 'react';
import { useUser } from '../context/UserContext';
const PAGE_SIZE = 10;
function EmailTemplatesPage() {
const { token, user } = useUser();
const [templates, setTemplates] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editTemplate, setEditTemplate] = useState(null);
const [editLoading, setEditLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(null);
const [createTemplate, setCreateTemplate] = useState(null);
const [createLoading, setCreateLoading] = useState(false);
useEffect(() => {
fetchTemplates(page);
// eslint-disable-next-line
}, [page]);
const fetchTemplates = async (page) => {
setLoading(true);
setError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/mail/email-templates?limit=${PAGE_SIZE}&offset=${offset}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка загрузки шаблонов');
setTemplates([]);
setTotal(0);
} else {
setTemplates(Array.isArray(data.rows) ? data.rows : []);
setTotal(data.count || 0);
}
} catch (e) {
setError('Ошибка сети');
setTemplates([]);
setTotal(0);
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Удалить шаблон?')) return;
setDeleteLoading(id);
try {
const res = await fetch(`/api/mail/email-templates/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка удаления');
} else {
fetchTemplates(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setDeleteLoading(null);
}
};
const handleEdit = (template) => {
setEditTemplate(template);
};
const handleEditSave = async (e) => {
e.preventDefault();
setEditLoading(true);
try {
const res = await fetch(`/api/mail/email-templates/${editTemplate.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...editTemplate, user_id: user?.id })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка обновления');
} else {
setEditTemplate(null);
fetchTemplates(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setEditLoading(false);
}
};
const handleCreate = () => {
setCreateTemplate({ name: '' });
};
const handleCreateSave = async (e) => {
e.preventDefault();
setCreateLoading(true);
try {
const res = await fetch('/api/mail/email-templates', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...createTemplate, user_id: user?.id })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка создания');
} else {
setCreateTemplate(null);
fetchTemplates(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setCreateLoading(false);
}
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить шаблон</button>
</div>
<h2>Шаблоны писем</h2>
{loading && <div>Загрузка...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Название</th>
<th style={thStyle}></th>
</tr>
</thead>
<tbody>
{templates.map(t => (
<tr key={t.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
<td style={tdStyle}>{t.id}</td>
<td style={tdStyle}>{t.name}</td>
<td style={tdStyle}>
<button onClick={() => handleEdit(t)} style={btnStyle}>Редактировать</button>
<button onClick={() => handleDelete(t.id)} style={btnStyle} 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>
)}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={paginatorWrapperStyle}>
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}></button>
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
style={{
...paginatorPageStyle,
...(page === i + 1 ? paginatorPageActiveStyle : {})
}}
disabled={page === i + 1}
>
{i + 1}
</button>
))}
<button onClick={() => setPage(page + 1)} disabled={page >= Math.ceil(total / PAGE_SIZE)} style={{ ...paginatorBtnStyle, opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}></button>
</div>
</div>
</>
)}
{editTemplate && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setEditTemplate(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={editLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Редактировать шаблон</h3>
<form onSubmit={handleEditSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Название
<input type="text" value={editTemplate.name} onChange={e => setEditTemplate({ ...editTemplate, name: e.target.value })} required style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={editLoading} style={saveBtnStyle}>{editLoading ? 'Сохранение...' : 'Сохранить'}</button>
<button type="button" onClick={() => setEditTemplate(null)} disabled={editLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</div>
</div>
)}
{createTemplate && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setCreateTemplate(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={createLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Добавить шаблон</h3>
<form onSubmit={handleCreateSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Название
<input type="text" value={createTemplate.name} onChange={e => setCreateTemplate({ ...createTemplate, name: e.target.value })} required style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={createLoading} style={saveBtnStyle}>{createLoading ? 'Создание...' : 'Создать'}</button>
<button type="button" onClick={() => setCreateTemplate(null)} disabled={createLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</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 modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { 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' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
const paginatorWrapperStyle = { marginTop: 24, display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'flex-end' };
const paginatorBtnStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 18, fontWeight: 700, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageActiveStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', cursor: 'default', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)' };
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

@ -0,0 +1,547 @@
import React, { useState, useEffect } from 'react';
import { useUser } from '../context/UserContext';
const PAGE_SIZE = 10;
function GroupsPage() {
const { token, user } = useUser();
const [groups, setGroups] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editGroup, setEditGroup] = useState(null);
const [editLoading, setEditLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(null);
const [createGroup, setCreateGroup] = useState(null);
const [createLoading, setCreateLoading] = useState(false);
const [selectedGroup, setSelectedGroup] = useState(null);
useEffect(() => {
if (!selectedGroup) fetchGroups(page);
// eslint-disable-next-line
}, [page, selectedGroup]);
const fetchGroups = async (page) => {
setLoading(true);
setError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/mail/mailing-groups?limit=${PAGE_SIZE}&offset=${offset}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка загрузки');
setGroups([]);
setTotal(0);
} else {
setGroups(Array.isArray(data.rows) ? data.rows : []);
setTotal(data.count || 0);
}
} catch (e) {
setError('Ошибка сети');
setGroups([]);
setTotal(0);
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Удалить группу?')) return;
setDeleteLoading(id);
try {
const res = await fetch(`/api/mail/mailing-groups/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка удаления');
} else {
fetchGroups(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setDeleteLoading(null);
}
};
const handleEdit = (group) => {
setEditGroup(group);
};
const handleEditSave = async (e) => {
e.preventDefault();
setEditLoading(true);
try {
const res = await fetch(`/api/mail/mailing-groups/${editGroup.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...editGroup, user_id: user?.id })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка обновления');
} else {
setEditGroup(null);
fetchGroups(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setEditLoading(false);
}
};
const handleCreate = () => {
setCreateGroup({ name: '', description: '' });
};
const handleCreateSave = async (e) => {
e.preventDefault();
setCreateLoading(true);
try {
const res = await fetch('/api/mail/mailing-groups', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...createGroup, user_id: user?.id })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка создания');
} else {
setCreateGroup(null);
fetchGroups(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setCreateLoading(false);
}
};
if (selectedGroup) {
return <SubscribersInGroupPage group={selectedGroup} onBack={() => setSelectedGroup(null)} token={token} />;
}
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить группу</button>
</div>
<h2>Группы подписчиков</h2>
{loading && <div>Загрузка...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Название</th>
<th style={thStyle}>Описание</th>
<th style={thStyle}></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}>
{deleteLoading === g.id ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{groups.length === 0 && (
<tr><td colSpan={4} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
)}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={paginatorWrapperStyle}>
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}></button>
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
style={{
...paginatorPageStyle,
...(page === i + 1 ? paginatorPageActiveStyle : {})
}}
disabled={page === i + 1}
>
{i + 1}
</button>
))}
<button onClick={() => setPage(page + 1)} disabled={page >= Math.ceil(total / PAGE_SIZE)} style={{ ...paginatorBtnStyle, opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}></button>
</div>
</div>
</>
)}
{editGroup && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setEditGroup(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={editLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Редактировать группу</h3>
<form onSubmit={handleEditSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Название
<input type="text" value={editGroup.name} onChange={e => setEditGroup({ ...editGroup, name: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Описание
<input type="text" value={editGroup.description || ''} onChange={e => setEditGroup({ ...editGroup, description: e.target.value })} style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={editLoading} style={saveBtnStyle}>{editLoading ? 'Сохранение...' : 'Сохранить'}</button>
<button type="button" onClick={() => setEditGroup(null)} disabled={editLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</div>
</div>
)}
{createGroup && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setCreateGroup(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={createLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Добавить группу</h3>
<form onSubmit={handleCreateSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Название
<input type="text" value={createGroup.name} onChange={e => setCreateGroup({ ...createGroup, name: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Описание
<input type="text" value={createGroup.description || ''} onChange={e => setCreateGroup({ ...createGroup, description: e.target.value })} style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={createLoading} style={saveBtnStyle}>{createLoading ? 'Создание...' : 'Создать'}</button>
<button type="button" onClick={() => setCreateGroup(null)} disabled={createLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
function SubscribersInGroupPage({ group, onBack, token }) {
const PAGE_SIZE = 10;
const [subs, setSubs] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editSub, setEditSub] = useState(null);
const [editLoading, setEditLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(null);
const [createSub, setCreateSub] = useState(null);
const [createLoading, setCreateLoading] = useState(false);
useEffect(() => {
fetchSubs(page);
// eslint-disable-next-line
}, [page, group.id]);
const fetchSubs = async (page) => {
setLoading(true);
setError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/mail/group-subscribers?limit=${PAGE_SIZE}&offset=${offset}&group_id=${group.id}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка загрузки');
setSubs([]);
setTotal(0);
} else {
setSubs(Array.isArray(data.rows) ? data.rows : []);
setTotal(data.count || 0);
}
} catch (e) {
setError('Ошибка сети');
setSubs([]);
setTotal(0);
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Удалить подписчика?')) return;
setDeleteLoading(id);
try {
const res = await fetch(`/api/mail/group-subscribers/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка удаления');
} else {
fetchSubs(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setDeleteLoading(null);
}
};
const handleEdit = (gs) => {
setEditSub({ ...gs.Subscriber, groupSubId: gs.id });
};
const handleEditSave = async (e) => {
e.preventDefault();
setEditLoading(true);
try {
const res = await fetch(`/api/mail/subscribers/${editSub.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(editSub)
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка обновления');
} else {
setEditSub(null);
fetchSubs(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setEditLoading(false);
}
};
const handleCreate = () => {
setCreateSub({ email: '', name: '', status: 'active' });
};
const handleCreateSave = async (e) => {
e.preventDefault();
setCreateLoading(true);
try {
// 1. Создать подписчика
const res = await fetch('/api/mail/subscribers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(createSub)
});
const data = await res.json();
if (!res.ok) {
alert(data.error || 'Ошибка создания');
} else {
// 2. Привязать к группе
const res2 = await fetch('/api/mail/group-subscribers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ group_id: group.id, subscriber_id: data.id })
});
if (!res2.ok) {
const data2 = await res2.json();
alert(data2.error || 'Ошибка привязки к группе');
} else {
setCreateSub(null);
fetchSubs(page);
}
}
} catch (e) {
alert('Ошибка сети');
} finally {
setCreateLoading(false);
}
};
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>
{loading && <div>Загрузка...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Имя</th>
<th style={thStyle}>Статус</th>
<th style={thStyle}></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}>
{deleteLoading === gs.id ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{subs.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
)}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={paginatorWrapperStyle}>
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}></button>
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
style={{
...paginatorPageStyle,
...(page === i + 1 ? paginatorPageActiveStyle : {})
}}
disabled={page === i + 1}
>
{i + 1}
</button>
))}
<button onClick={() => setPage(page + 1)} disabled={page >= Math.ceil(total / PAGE_SIZE)} style={{ ...paginatorBtnStyle, opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}></button>
</div>
</div>
</>
)}
{editSub && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setEditSub(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={editLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Редактировать подписчика</h3>
<form onSubmit={handleEditSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Email
<input type="email" value={editSub.email} onChange={e => setEditSub({ ...editSub, email: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Имя
<input type="text" value={editSub.name || ''} onChange={e => setEditSub({ ...editSub, name: e.target.value })} style={inputStyle} />
</label>
<label style={labelStyle}>Статус
<select value={editSub.status} onChange={e => setEditSub({ ...editSub, status: e.target.value })} required style={inputStyle}>
<option value="active">Активен</option>
<option value="unsubscribed">Отписан</option>
<option value="bounced">Ошибка</option>
</select>
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={editLoading} style={saveBtnStyle}>{editLoading ? 'Сохранение...' : 'Сохранить'}</button>
<button type="button" onClick={() => setEditSub(null)} disabled={editLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</div>
</div>
)}
{createSub && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setCreateSub(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={createLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Добавить подписчика</h3>
<form onSubmit={handleCreateSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Email
<input type="email" value={createSub.email} onChange={e => setCreateSub({ ...createSub, email: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Имя
<input type="text" value={createSub.name || ''} onChange={e => setCreateSub({ ...createSub, name: e.target.value })} style={inputStyle} />
</label>
<label style={labelStyle}>Статус
<select value={createSub.status} onChange={e => setCreateSub({ ...createSub, status: e.target.value })} required style={inputStyle}>
<option value="active">Активен</option>
<option value="unsubscribed">Отписан</option>
<option value="bounced">Ошибка</option>
</select>
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={createLoading} style={saveBtnStyle}>{createLoading ? 'Создание...' : 'Создать'}</button>
<button type="button" onClick={() => setCreateSub(null)} disabled={createLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</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 modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { 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' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
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' };
const paginatorWrapperStyle = { marginTop: 24, display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'flex-end' };
const paginatorBtnStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 18, fontWeight: 700, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageActiveStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', cursor: 'default', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)' };
export default GroupsPage;

View File

@ -14,7 +14,7 @@ const Login = () => {
const handleSubmit = async (e) => { const handleSubmit = async (e) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
const res = await fetch('/api/auth/users/login', { const res = await fetch('/api/auth/users/login', {
method: 'POST', method: 'POST',
@ -26,7 +26,7 @@ const Login = () => {
setError(data.error || 'Ошибка входа'); setError(data.error || 'Ошибка входа');
} else { } else {
login(data.user, data.token); login(data.user, data.token);
navigate('/dashboard'); navigate('/dashboard');
} }
} catch (err) { } catch (err) {
setError('Ошибка сети'); setError('Ошибка сети');

View File

@ -0,0 +1,314 @@
import React, { useState, useEffect } from 'react';
import { useUser } from '../context/UserContext';
const PAGE_SIZE = 10;
function SmtpServersPage() {
const { token, user } = useUser();
const [servers, setServers] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editServer, setEditServer] = useState(null);
const [editLoading, setEditLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(null);
const [createServer, setCreateServer] = useState(null);
const [createLoading, setCreateLoading] = useState(false);
useEffect(() => {
fetchServers(page);
// eslint-disable-next-line
}, [page]);
const fetchServers = async (page) => {
setLoading(true);
setError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/mail/smtp-servers?limit=${PAGE_SIZE}&offset=${offset}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка загрузки SMTP-серверов');
setServers([]);
setTotal(0);
} else {
setServers(Array.isArray(data) ? data : data.rows || []);
setTotal(data.count || (Array.isArray(data) ? data.length : 0));
}
} catch (e) {
setError('Ошибка сети');
setServers([]);
setTotal(0);
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Удалить SMTP-сервер?')) return;
setDeleteLoading(id);
try {
const res = await fetch(`/api/mail/smtp-servers/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка удаления');
} else {
fetchServers(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setDeleteLoading(null);
}
};
const handleEdit = (server) => {
setEditServer(server);
};
const handleEditSave = async (e) => {
e.preventDefault();
setEditLoading(true);
try {
const { group_id, ...serverData } = editServer;
const res = await fetch(`/api/mail/smtp-servers/${editServer.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...serverData, user_id: user?.id })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка обновления');
} else {
setEditServer(null);
fetchServers(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setEditLoading(false);
}
};
const handleCreate = () => {
setCreateServer({
name: '', host: '', port: 587, secure: false, username: '', password: '', from_email: ''
});
};
const handleCreateSave = async (e) => {
e.preventDefault();
setCreateLoading(true);
try {
const { group_id, ...serverData } = createServer;
const res = await fetch('/api/mail/smtp-servers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...serverData, user_id: user?.id })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка создания');
} else {
setCreateServer(null);
fetchServers(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setCreateLoading(false);
}
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить SMTP-сервер</button>
</div>
<h2>SMTP-серверы</h2>
{loading && <div>Загрузка...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<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>
</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}>
{deleteLoading === s.id ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{servers.length === 0 && (
<tr><td colSpan={8} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
)}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={paginatorWrapperStyle}>
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}></button>
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
style={{
...paginatorPageStyle,
...(page === i + 1 ? paginatorPageActiveStyle : {})
}}
disabled={page === i + 1}
>
{i + 1}
</button>
))}
<button onClick={() => setPage(page + 1)} disabled={page >= Math.ceil(total / PAGE_SIZE)} style={{ ...paginatorBtnStyle, opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}></button>
</div>
</div>
</>
)}
{editServer && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setEditServer(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={editLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Редактировать SMTP-сервер</h3>
<form onSubmit={handleEditSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Название
<input type="text" value={editServer.name} onChange={e => setEditServer({ ...editServer, name: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Host
<input type="text" value={editServer.host} onChange={e => setEditServer({ ...editServer, host: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Port
<input type="number" value={editServer.port} onChange={e => setEditServer({ ...editServer, port: Number(e.target.value) })} required style={inputStyle} />
</label>
<label style={labelStyle}>Secure
<select value={editServer.secure ? '1' : '0'} onChange={e => setEditServer({ ...editServer, secure: e.target.value === '1' })} style={inputStyle}>
<option value="0">Нет</option>
<option value="1">Да</option>
</select>
</label>
<label style={labelStyle}>Пользователь
<input type="text" value={editServer.username} onChange={e => setEditServer({ ...editServer, username: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Пароль
<input type="password" value={editServer.password} onChange={e => setEditServer({ ...editServer, password: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Отправитель (from_email)
<input type="email" value={editServer.from_email} onChange={e => setEditServer({ ...editServer, from_email: e.target.value })} required style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={editLoading} style={saveBtnStyle}>{editLoading ? 'Сохранение...' : 'Сохранить'}</button>
<button type="button" onClick={() => setEditServer(null)} disabled={editLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</div>
</div>
)}
{createServer && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setCreateServer(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={createLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Добавить SMTP-сервер</h3>
<form onSubmit={handleCreateSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Название
<input type="text" value={createServer.name} onChange={e => setCreateServer({ ...createServer, name: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Host
<input type="text" value={createServer.host} onChange={e => setCreateServer({ ...createServer, host: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Port
<input type="number" value={createServer.port} onChange={e => setCreateServer({ ...createServer, port: Number(e.target.value) })} required style={inputStyle} />
</label>
<label style={labelStyle}>Secure
<select value={createServer.secure ? '1' : '0'} onChange={e => setCreateServer({ ...createServer, secure: e.target.value === '1' })} style={inputStyle}>
<option value="0">Нет</option>
<option value="1">Да</option>
</select>
</label>
<label style={labelStyle}>Пользователь
<input type="text" value={createServer.username} onChange={e => setCreateServer({ ...createServer, username: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Пароль
<input type="password" value={createServer.password} onChange={e => setCreateServer({ ...createServer, password: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Отправитель (from_email)
<input type="email" value={createServer.from_email} onChange={e => setCreateServer({ ...createServer, from_email: e.target.value })} required style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={createLoading} style={saveBtnStyle}>{createLoading ? 'Создание...' : 'Создать'}</button>
<button type="button" onClick={() => setCreateServer(null)} disabled={createLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</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 modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { 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' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
const paginatorWrapperStyle = { marginTop: 24, display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'flex-end' };
const paginatorBtnStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 18, fontWeight: 700, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageActiveStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', cursor: 'default', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)' };
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

@ -0,0 +1,268 @@
import React, { useState, useEffect } from 'react';
import { useUser } from '../context/UserContext';
const PAGE_SIZE = 10;
function UnsubscribedPage() {
const { token } = useUser();
const [subs, setSubs] = useState([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [editSub, setEditSub] = useState(null);
const [editLoading, setEditLoading] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(null);
const [createSub, setCreateSub] = useState(null);
const [createLoading, setCreateLoading] = useState(false);
useEffect(() => {
fetchSubs(page);
// eslint-disable-next-line
}, [page]);
const fetchSubs = async (page) => {
setLoading(true);
setError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/mail/subscribers?limit=${PAGE_SIZE}&offset=${offset}&status=unsubscribed`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Ошибка загрузки');
setSubs([]);
setTotal(0);
} else {
setSubs(Array.isArray(data.rows) ? data.rows : []);
setTotal(data.count || 0);
}
} catch (e) {
setError('Ошибка сети');
setSubs([]);
setTotal(0);
} finally {
setLoading(false);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Удалить запись?')) return;
setDeleteLoading(id);
try {
const res = await fetch(`/api/mail/subscribers/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка удаления');
} else {
fetchSubs(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setDeleteLoading(null);
}
};
const handleEdit = (sub) => {
setEditSub(sub);
};
const handleEditSave = async (e) => {
e.preventDefault();
setEditLoading(true);
try {
const res = await fetch(`/api/mail/subscribers/${editSub.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...editSub, status: 'unsubscribed' })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка обновления');
} else {
setEditSub(null);
fetchSubs(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setEditLoading(false);
}
};
const handleCreate = () => {
setCreateSub({ email: '', name: '', status: 'unsubscribed' });
};
const handleCreateSave = async (e) => {
e.preventDefault();
setCreateLoading(true);
try {
const res = await fetch('/api/mail/subscribers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({ ...createSub, status: 'unsubscribed' })
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка создания');
} else {
setCreateSub(null);
fetchSubs(page);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setCreateLoading(false);
}
};
return (
<div>
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
<button onClick={handleCreate} style={addBtnStyle}>+ Добавить запись</button>
</div>
<h2>Отписались</h2>
{loading && <div>Загрузка...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
{!loading && !error && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Имя</th>
<th style={thStyle}>Дата отписки</th>
<th style={thStyle}></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}>
{deleteLoading === s.id ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{subs.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
)}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={paginatorWrapperStyle}>
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}></button>
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
<button
key={i + 1}
onClick={() => setPage(i + 1)}
style={{
...paginatorPageStyle,
...(page === i + 1 ? paginatorPageActiveStyle : {})
}}
disabled={page === i + 1}
>
{i + 1}
</button>
))}
<button onClick={() => setPage(page + 1)} disabled={page >= Math.ceil(total / PAGE_SIZE)} style={{ ...paginatorBtnStyle, opacity: page >= Math.ceil(total / PAGE_SIZE) ? 0.5 : 1 }}></button>
</div>
</div>
</>
)}
{editSub && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setEditSub(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={editLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Редактировать запись</h3>
<form onSubmit={handleEditSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Email
<input type="email" value={editSub.email} onChange={e => setEditSub({ ...editSub, email: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Имя
<input type="text" value={editSub.name || ''} onChange={e => setEditSub({ ...editSub, name: e.target.value })} style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={editLoading} style={saveBtnStyle}>{editLoading ? 'Сохранение...' : 'Сохранить'}</button>
<button type="button" onClick={() => setEditSub(null)} disabled={editLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</div>
</div>
)}
{createSub && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setCreateSub(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={createLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Добавить запись</h3>
<form onSubmit={handleCreateSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Email
<input type="email" value={createSub.email} onChange={e => setCreateSub({ ...createSub, email: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Имя
<input type="text" value={createSub.name || ''} onChange={e => setCreateSub({ ...createSub, name: e.target.value })} style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={createLoading} style={saveBtnStyle}>{createLoading ? 'Создание...' : 'Создать'}</button>
<button type="button" onClick={() => setCreateSub(null)} disabled={createLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</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 modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { 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' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
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' };
const paginatorWrapperStyle = { marginTop: 24, display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'flex-end' };
const paginatorBtnStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 18, fontWeight: 700, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageActiveStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', cursor: 'default', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)' };
export default UnsubscribedPage;

View File

@ -0,0 +1,318 @@
import React, { useState, useEffect } from 'react';
import { useUser } from '../context/UserContext';
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 [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);
// eslint-disable-next-line
}, [usersPage]);
useEffect(() => {
fetchRoles();
// eslint-disable-next-line
}, []);
const fetchUsers = async (page) => {
setUsersLoading(true);
setUsersError('');
try {
const offset = (page - 1) * PAGE_SIZE;
const res = await fetch(`/api/auth/users?limit=${PAGE_SIZE}&offset=${offset}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const data = await res.json();
if (!res.ok) {
setUsersError(data.error || 'Ошибка загрузки пользователей');
setUsers([]);
setUsersTotal(0);
} else {
setUsers(Array.isArray(data) ? data : data.rows || []);
setUsersTotal(data.count || (Array.isArray(data) ? data.length : 0));
}
} catch (e) {
setUsersError('Ошибка сети');
setUsers([]);
setUsersTotal(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();
if (res.ok && Array.isArray(data)) {
setRoles(data);
} else {
setRoles([]);
}
} catch {
setRoles([]);
}
};
const handleDelete = async (id) => {
if (!window.confirm('Удалить пользователя?')) return;
setDeleteLoading(id);
try {
const res = await fetch(`/api/auth/users/${id}`, {
method: 'DELETE',
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка удаления');
} else {
fetchUsers(usersPage);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setDeleteLoading(null);
}
};
const handleEdit = (user) => {
setEditUser(user);
};
const handleEditSave = async (e) => {
e.preventDefault();
setEditLoading(true);
try {
const res = await fetch(`/api/auth/users/${editUser.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify({
email: editUser.email,
name: editUser.name,
role_id: editUser.role_id
})
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка обновления');
} else {
setEditUser(null);
fetchUsers(usersPage);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setEditLoading(false);
}
};
const handleCreate = () => {
setCreateUser({ email: '', name: '', role_id: roles[0]?.id || 1, password: '' });
};
const handleCreateSave = async (e) => {
e.preventDefault();
setCreateLoading(true);
try {
const res = await fetch('/api/auth/users', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {})
},
body: JSON.stringify(createUser)
});
if (!res.ok) {
const data = await res.json();
alert(data.error || 'Ошибка создания');
} else {
setCreateUser(null);
fetchUsers(usersPage);
}
} catch (e) {
alert('Ошибка сети');
} finally {
setCreateLoading(false);
}
};
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>
<h2>Пользователи</h2>
{usersLoading && <div>Загрузка...</div>}
{usersError && <div style={{ color: 'red' }}>{usersError}</div>}
{!usersLoading && !usersError && (
<>
<table style={{ width: '100%', borderCollapse: 'collapse', marginTop: 16, background: '#fff', borderRadius: 8, overflow: 'hidden' }}>
<thead style={{ background: '#f3f4f6' }}>
<tr>
<th style={thStyle}>ID</th>
<th style={thStyle}>Email</th>
<th style={thStyle}>Имя</th>
<th style={thStyle}>Роль</th>
<th style={thStyle}></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}>
{deleteLoading === u.id ? 'Удаление...' : 'Удалить'}
</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr><td colSpan={5} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет данных</td></tr>
)}
</tbody>
</table>
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
<div style={paginatorWrapperStyle}>
<button onClick={() => setUsersPage(usersPage - 1)} disabled={usersPage <= 1} style={{ ...paginatorBtnStyle, opacity: usersPage <= 1 ? 0.5 : 1 }}></button>
{Array.from({ length: Math.ceil(usersTotal / PAGE_SIZE) || 1 }, (_, i) => (
<button
key={i + 1}
onClick={() => setUsersPage(i + 1)}
style={{
...paginatorPageStyle,
...(usersPage === i + 1 ? paginatorPageActiveStyle : {})
}}
disabled={usersPage === i + 1}
>
{i + 1}
</button>
))}
<button onClick={() => setUsersPage(usersPage + 1)} disabled={usersPage >= Math.ceil(usersTotal / PAGE_SIZE)} style={{ ...paginatorBtnStyle, opacity: usersPage >= Math.ceil(usersTotal / PAGE_SIZE) ? 0.5 : 1 }}></button>
</div>
</div>
</>
)}
{editUser && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setEditUser(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={editLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Редактировать пользователя</h3>
<form onSubmit={handleEditSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Email
<input type="email" value={editUser.email} onChange={e => setEditUser({ ...editUser, email: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Имя
<input type="text" value={editUser.name} onChange={e => setEditUser({ ...editUser, name: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Роль
<select value={editUser.role_id} onChange={e => setEditUser({ ...editUser, role_id: Number(e.target.value) })} required style={inputStyle}>
{roles.map(role => (
<option key={role.id} value={role.id}>{role.name}</option>
))}
</select>
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={editLoading} style={saveBtnStyle}>{editLoading ? 'Сохранение...' : 'Сохранить'}</button>
<button type="button" onClick={() => setEditUser(null)} disabled={editLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</div>
</div>
)}
{createUser && (
<div style={modalOverlayStyle}>
<div style={modalStyle}>
<button
type="button"
onClick={() => setCreateUser(null)}
style={closeBtnStyle}
aria-label="Закрыть"
disabled={createLoading}
>
×
</button>
<h3 style={{ margin: '0 0 18px 0', textAlign: 'center', color: '#3730a3', fontWeight: 700 }}>Добавить пользователя</h3>
<form onSubmit={handleCreateSave} style={{ display: 'flex', flexDirection: 'column', gap: 16, minWidth: 260 }}>
<label style={labelStyle}>Email
<input type="email" value={createUser.email} onChange={e => setCreateUser({ ...createUser, email: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Имя
<input type="text" value={createUser.name} onChange={e => setCreateUser({ ...createUser, name: e.target.value })} required style={inputStyle} />
</label>
<label style={labelStyle}>Роль
<select value={createUser.role_id} onChange={e => setCreateUser({ ...createUser, role_id: Number(e.target.value) })} required style={inputStyle}>
{roles.map(role => (
<option key={role.id} value={role.id}>{role.name}</option>
))}
</select>
</label>
<label style={labelStyle}>Пароль
<input type="password" value={createUser.password} onChange={e => setCreateUser({ ...createUser, password: e.target.value })} required style={inputStyle} />
</label>
<div style={{ display: 'flex', gap: 12, marginTop: 10, justifyContent: 'flex-end' }}>
<button type="submit" disabled={createLoading} style={saveBtnStyle}>{createLoading ? 'Создание...' : 'Создать'}</button>
<button type="button" onClick={() => setCreateUser(null)} disabled={createLoading} style={cancelBtnStyle}>Отмена</button>
</div>
</form>
</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 modalOverlayStyle = { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.18)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000 };
const modalStyle = { background: '#fff', borderRadius: 14, padding: '32px 28px 24px 28px', minWidth: 340, boxShadow: '0 12px 48px 0 rgba(31,38,135,0.22)', position: 'relative', animation: 'modalIn 0.18s cubic-bezier(.4,1.3,.6,1)'};
const closeBtnStyle = { position: 'absolute', top: 12, right: 16, background: 'none', border: 'none', fontSize: 26, color: '#6366f1', cursor: 'pointer', fontWeight: 700, lineHeight: 1, padding: 0, zIndex: 2, opacity: 0.8, transition: 'opacity 0.2s' };
const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: 'flex', flexDirection: 'column', gap: 4 };
const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border: '1.5px solid #c7d2fe', fontSize: 16, outline: 'none', background: '#f8fafc', transition: 'border 0.2s' };
const saveBtnStyle = { 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' };
const cancelBtnStyle = { background: '#f3f4f6', color: '#6366f1', border: 'none', borderRadius: 8, padding: '10px 18px', fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.2s' };
const paginatorWrapperStyle = { marginTop: 24, display: 'flex', gap: 6, alignItems: 'center', justifyContent: 'flex-end' };
const paginatorBtnStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 18, fontWeight: 700, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageStyle = { border: 'none', background: '#f3f4f6', color: '#6366f1', borderRadius: '50%', width: 36, height: 36, fontSize: 16, fontWeight: 600, cursor: 'pointer', transition: 'background 0.18s, color 0.18s', boxShadow: '0 1px 4px 0 rgba(99,102,241,0.06)' };
const paginatorPageActiveStyle = { background: 'linear-gradient(90deg, #6366f1 0%, #06b6d4 100%)', color: '#fff', cursor: 'default', boxShadow: '0 2px 8px 0 rgba(99,102,241,0.10)' };
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

@ -11,8 +11,15 @@ export default {
}, },
async getAll(req, res) { async getAll(req, res) {
try { try {
const campaigns = await Campaign.findAll({ include: [EmailTemplateVersion, MailingGroup] }); const limit = parseInt(req.query.limit) || 20;
res.json(campaigns); const offset = parseInt(req.query.offset) || 0;
const result = await Campaign.findAndCountAll({
include: [EmailTemplateVersion, MailingGroup],
limit,
offset,
order: [['id', 'ASC']]
});
res.json({ count: result.count, rows: result.rows });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }

View File

@ -11,8 +11,15 @@ export default {
}, },
async getAll(req, res) { async getAll(req, res) {
try { try {
const logs = await DeliveryLog.findAll({ include: [Campaign, Subscriber] }); const limit = parseInt(req.query.limit) || 20;
res.json(logs); const offset = parseInt(req.query.offset) || 0;
const result = await DeliveryLog.findAndCountAll({
include: [Campaign, Subscriber],
limit,
offset,
order: [['id', 'ASC']]
});
res.json({ count: result.count, rows: result.rows });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }

View File

@ -11,8 +11,15 @@ export default {
}, },
async getAll(req, res) { async getAll(req, res) {
try { try {
const templates = await EmailTemplate.findAll({ include: EmailTemplateVersion }); const limit = parseInt(req.query.limit) || 20;
res.json(templates); const offset = parseInt(req.query.offset) || 0;
const result = await EmailTemplate.findAndCountAll({
include: EmailTemplateVersion,
limit,
offset,
order: [['id', 'ASC']]
});
res.json({ count: result.count, rows: result.rows });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }

View File

@ -11,8 +11,15 @@ export default {
}, },
async getAll(req, res) { async getAll(req, res) {
try { try {
const groupSubscribers = await GroupSubscriber.findAll({ include: [MailingGroup, Subscriber] }); const limit = parseInt(req.query.limit) || 20;
res.json(groupSubscribers); const offset = parseInt(req.query.offset) || 0;
const result = await GroupSubscriber.findAndCountAll({
include: [MailingGroup, Subscriber],
limit,
offset,
order: [['id', 'ASC']]
});
res.json({ count: result.count, rows: result.rows });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }

View File

@ -11,8 +11,14 @@ export default {
}, },
async getAll(req, res) { async getAll(req, res) {
try { try {
const groups = await MailingGroup.findAll({ include: SmtpServer }); const limit = parseInt(req.query.limit) || 20;
res.json(groups); const offset = parseInt(req.query.offset) || 0;
const result = await MailingGroup.findAndCountAll({
limit,
offset,
order: [['id', 'ASC']]
});
res.json({ count: result.count, rows: result.rows });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }

View File

@ -1,8 +1,9 @@
import { SmtpServer, MailingGroup } from '../models/index.js'; import { SmtpServer } from '../models/index.js';
export default { export default {
async create(req, res) { async create(req, res) {
try { try {
// group_id удалено
const smtp = await SmtpServer.create(req.body); const smtp = await SmtpServer.create(req.body);
res.status(201).json(smtp); res.status(201).json(smtp);
} catch (err) { } catch (err) {
@ -11,15 +12,18 @@ export default {
}, },
async getAll(req, res) { async getAll(req, res) {
try { try {
const servers = await SmtpServer.findAll({ include: MailingGroup }); const limit = parseInt(req.query.limit) || 20;
res.json(servers); const offset = parseInt(req.query.offset) || 0;
const result = await SmtpServer.findAndCountAll({ limit, offset, order: [['id', 'ASC']] });
res.json({ count: result.count, rows: result.rows });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }
}, },
async getById(req, res) { async getById(req, res) {
try { try {
const smtp = await SmtpServer.findByPk(req.params.id, { include: MailingGroup }); // include: MailingGroup удалено
const smtp = await SmtpServer.findByPk(req.params.id);
if (!smtp) return res.status(404).json({ error: 'SmtpServer not found' }); if (!smtp) return res.status(404).json({ error: 'SmtpServer not found' });
res.json(smtp); res.json(smtp);
} catch (err) { } catch (err) {
@ -30,6 +34,7 @@ export default {
try { try {
const smtp = await SmtpServer.findByPk(req.params.id); const smtp = await SmtpServer.findByPk(req.params.id);
if (!smtp) return res.status(404).json({ error: 'SmtpServer not found' }); if (!smtp) return res.status(404).json({ error: 'SmtpServer not found' });
// group_id удалено
await smtp.update(req.body); await smtp.update(req.body);
res.json(smtp); res.json(smtp);
} catch (err) { } catch (err) {

View File

@ -11,8 +11,10 @@ export default {
}, },
async getAll(req, res) { async getAll(req, res) {
try { try {
const subscribers = await Subscriber.findAll(); const limit = parseInt(req.query.limit) || 20;
res.json(subscribers); const offset = parseInt(req.query.offset) || 0;
const result = await Subscriber.findAndCountAll({ limit, offset, order: [['id', 'ASC']] });
res.json({ count: result.count, rows: result.rows });
} catch (err) { } catch (err) {
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
} }

View File

@ -44,8 +44,7 @@ Campaign.belongsTo(MailingGroup, { foreignKey: 'group_id' });
DeliveryLog.belongsTo(Campaign, { foreignKey: 'campaign_id' }); DeliveryLog.belongsTo(Campaign, { foreignKey: 'campaign_id' });
DeliveryLog.belongsTo(Subscriber, { foreignKey: 'subscriber_id' }); DeliveryLog.belongsTo(Subscriber, { foreignKey: 'subscriber_id' });
SmtpServer.belongsTo(MailingGroup, { foreignKey: 'group_id' }); // Удалены связи SmtpServer <-> MailingGroup по group_id
MailingGroup.hasMany(SmtpServer, { foreignKey: 'group_id' });
// (связи с user_id только по полю, без внешнего ключа на User) // (связи с user_id только по полю, без внешнего ключа на User)

View File

@ -4,7 +4,6 @@ export default (sequelize) => {
const SmtpServer = sequelize.define('SmtpServer', { const SmtpServer = sequelize.define('SmtpServer', {
id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
user_id: { type: DataTypes.INTEGER, allowNull: false }, user_id: { type: DataTypes.INTEGER, allowNull: false },
group_id: { type: DataTypes.INTEGER, allowNull: true }, // связь с группой подписчиков
name: { type: DataTypes.STRING, allowNull: false }, name: { type: DataTypes.STRING, allowNull: false },
host: { type: DataTypes.STRING, allowNull: false }, host: { type: DataTypes.STRING, allowNull: false },
port: { type: DataTypes.INTEGER, allowNull: false }, port: { type: DataTypes.INTEGER, allowNull: false },