From bee42c5b16990e9ad3780ed53500e44d4634b283 Mon Sep 17 00:00:00 2001 From: romantarkin Date: Wed, 23 Jul 2025 13:57:53 +0500 Subject: [PATCH] CRUD --- .../src/controllers/userController.js | 11 +- frontend/src/index.js | 2 +- frontend/src/pages/CampaignPage.js | 372 ++++++++++++ frontend/src/pages/Dashboard.js | 39 +- frontend/src/pages/DeliveryHistoryPage.js | 115 ++++ frontend/src/pages/EmailTemplatesPage.js | 258 +++++++++ frontend/src/pages/GroupsPage.js | 547 ++++++++++++++++++ frontend/src/pages/Login.js | 4 +- frontend/src/pages/SmtpServersPage.js | 314 ++++++++++ frontend/src/pages/UnsubscribedPage.js | 268 +++++++++ frontend/src/pages/UsersPage.js | 318 ++++++++++ .../src/controllers/campaignController.js | 11 +- .../src/controllers/deliveryLogController.js | 11 +- .../controllers/emailTemplateController.js | 11 +- .../controllers/groupSubscriberController.js | 11 +- .../src/controllers/mailingGroupController.js | 10 +- .../src/controllers/smtpServerController.js | 13 +- .../src/controllers/subscriberController.js | 6 +- mail-service/src/models/index.js | 3 +- mail-service/src/models/smtpServer.js | 1 - 20 files changed, 2283 insertions(+), 42 deletions(-) create mode 100644 frontend/src/pages/CampaignPage.js create mode 100644 frontend/src/pages/DeliveryHistoryPage.js create mode 100644 frontend/src/pages/EmailTemplatesPage.js create mode 100644 frontend/src/pages/GroupsPage.js create mode 100644 frontend/src/pages/SmtpServersPage.js create mode 100644 frontend/src/pages/UnsubscribedPage.js create mode 100644 frontend/src/pages/UsersPage.js diff --git a/auth-service/src/controllers/userController.js b/auth-service/src/controllers/userController.js index 76d7a59..fc70355 100644 --- a/auth-service/src/controllers/userController.js +++ b/auth-service/src/controllers/userController.js @@ -17,8 +17,15 @@ export default { }, async getAll(req, res) { try { - const users = await User.findAll({ include: Role }); - res.json(users); + const limit = parseInt(req.query.limit) || 20; + 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) { res.status(500).json({ error: err.message }); } diff --git a/frontend/src/index.js b/frontend/src/index.js index 2b7667e..eeb9a90 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -9,7 +9,7 @@ const root = ReactDOM.createRoot(document.getElementById('root')); root.render( - + ); diff --git a/frontend/src/pages/CampaignPage.js b/frontend/src/pages/CampaignPage.js new file mode 100644 index 0000000..fc020ad --- /dev/null +++ b/frontend/src/pages/CampaignPage.js @@ -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 ( +
+
+ +
+

Кампания

+ {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( + <> + + + + + + + + + + + + + + {campaigns.map(c => ( + + + + + + + + + + ))} + {campaigns.length === 0 && ( + + )} + +
IDГруппаВерсия шаблонаТемаСтатусЗапланировано
{c.id}{getGroupName(c.group_id)}{getVersionName(c.template_version_id)}{c.subject_override || ''}{c.status}{c.scheduled_at ? new Date(c.scheduled_at).toLocaleString() : ''} + + +
Нет данных
+
+
+ + {Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => ( + + ))} + +
+
+ + )} + {editCampaign && ( +
+
+ +

Редактировать кампанию

+
+ + + + + +
+ + +
+
+
+
+ )} + {createCampaign && ( +
+
+ +

Добавить кампанию

+
+ + + + + +
+ + +
+
+
+
+ )} +
+ ); +} + +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; \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.js b/frontend/src/pages/Dashboard.js index 7472390..5009d90 100644 --- a/frontend/src/pages/Dashboard.js +++ b/frontend/src/pages/Dashboard.js @@ -2,6 +2,13 @@ import React, { useState } from 'react'; import SideMenu from '../components/SideMenu'; import Header from '../components/Header'; 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 [active, setActive] = useState('smtp'); @@ -12,34 +19,30 @@ const Dashboard = () => { window.location.href = '/login'; }; + function renderPage() { + switch (active) { + case 'users': return ; + case 'smtp': return ; + case 'template': return ; + case 'unsubscribed': return ; + case 'groups': return ; + case 'history': return ; + case 'campaign': return ; + default: return null; + } + } + return (
- {/* Здесь будет контент выбранного раздела */} -

Раздел: {getSectionTitle(active)}

-
- Здесь будет содержимое для "{getSectionTitle(active)}". -
+ {renderPage()}
); }; -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; \ No newline at end of file diff --git a/frontend/src/pages/DeliveryHistoryPage.js b/frontend/src/pages/DeliveryHistoryPage.js new file mode 100644 index 0000000..4225547 --- /dev/null +++ b/frontend/src/pages/DeliveryHistoryPage.js @@ -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 ( +
+

История отправок

+ {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( + <> + + + + + + + + + + + + + + + {logs.map(l => ( + + + + + + + + + + + ))} + {logs.length === 0 && ( + + )} + +
IDEmailСтатусДата отправкиОткрытоКликОшибкаКампания
{l.id}{l.Subscriber?.email || l.subscriber_id}{l.status}{l.sent_at ? new Date(l.sent_at).toLocaleString() : ''}{l.opened_at ? new Date(l.opened_at).toLocaleString() : ''}{l.clicked_at ? new Date(l.clicked_at).toLocaleString() : ''}{l.error_message || ''}{l.campaign_id}
Нет данных
+
+
+ + {Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => ( + + ))} + +
+
+ + )} +
+ ); +} + +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; \ No newline at end of file diff --git a/frontend/src/pages/EmailTemplatesPage.js b/frontend/src/pages/EmailTemplatesPage.js new file mode 100644 index 0000000..8d12560 --- /dev/null +++ b/frontend/src/pages/EmailTemplatesPage.js @@ -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 ( +
+
+ +
+

Шаблоны писем

+ {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( + <> + + + + + + + + + + {templates.map(t => ( + + + + + + ))} + {templates.length === 0 && ( + + )} + +
IDНазвание
{t.id}{t.name} + + +
Нет данных
+
+
+ + {Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => ( + + ))} + +
+
+ + )} + {editTemplate && ( +
+
+ +

Редактировать шаблон

+
+ +
+ + +
+
+
+
+ )} + {createTemplate && ( +
+
+ +

Добавить шаблон

+
+ +
+ + +
+
+
+
+ )} +
+ ); +} + +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; \ No newline at end of file diff --git a/frontend/src/pages/GroupsPage.js b/frontend/src/pages/GroupsPage.js new file mode 100644 index 0000000..59fb6c4 --- /dev/null +++ b/frontend/src/pages/GroupsPage.js @@ -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 setSelectedGroup(null)} token={token} />; + } + + return ( +
+
+ +
+

Группы подписчиков

+ {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( + <> + + + + + + + + + + + {groups.map(g => ( + setSelectedGroup(g)}> + + + + + + ))} + {groups.length === 0 && ( + + )} + +
IDНазваниеОписание
{g.id}{g.name}{g.description} + + +
Нет данных
+
+
+ + {Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => ( + + ))} + +
+
+ + )} + {editGroup && ( +
+
+ +

Редактировать группу

+
+ + +
+ + +
+
+
+
+ )} + {createGroup && ( +
+
+ +

Добавить группу

+
+ + +
+ + +
+
+
+
+ )} +
+ ); +} + +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 ( +
+ +

Подписчики группы: {group.name}

+
+ +
+ {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( + <> + + + + + + + + + + + + {subs.map(gs => ( + + + + + + + + ))} + {subs.length === 0 && ( + + )} + +
IDEmailИмяСтатус
{gs.Subscriber?.id || ''}{gs.Subscriber?.email || ''}{gs.Subscriber?.name || ''}{gs.Subscriber?.status || ''} + + +
Нет данных
+
+
+ + {Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => ( + + ))} + +
+
+ + )} + {editSub && ( +
+
+ +

Редактировать подписчика

+
+ + + +
+ + +
+
+
+
+ )} + {createSub && ( +
+
+ +

Добавить подписчика

+
+ + + +
+ + +
+
+
+
+ )} +
+ ); +} + +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; \ No newline at end of file diff --git a/frontend/src/pages/Login.js b/frontend/src/pages/Login.js index f36a52e..5d91429 100644 --- a/frontend/src/pages/Login.js +++ b/frontend/src/pages/Login.js @@ -14,7 +14,7 @@ const Login = () => { const handleSubmit = async (e) => { e.preventDefault(); setLoading(true); - setError(''); + setError(''); try { const res = await fetch('/api/auth/users/login', { method: 'POST', @@ -26,7 +26,7 @@ const Login = () => { setError(data.error || 'Ошибка входа'); } else { login(data.user, data.token); - navigate('/dashboard'); + navigate('/dashboard'); } } catch (err) { setError('Ошибка сети'); diff --git a/frontend/src/pages/SmtpServersPage.js b/frontend/src/pages/SmtpServersPage.js new file mode 100644 index 0000000..c2d533e --- /dev/null +++ b/frontend/src/pages/SmtpServersPage.js @@ -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 ( +
+
+ +
+

SMTP-серверы

+ {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( + <> + + + + + + + + + + + + + + + {servers.map(s => ( + + + + + + + + + + + ))} + {servers.length === 0 && ( + + )} + +
IDНазваниеHostPortSecureПользовательОтправитель
{s.id}{s.name}{s.host}{s.port}{s.secure ? 'Да' : 'Нет'}{s.username}{s.from_email} + + +
Нет данных
+
+
+ + {Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => ( + + ))} + +
+
+ + )} + {editServer && ( +
+
+ +

Редактировать SMTP-сервер

+
+ + + + + + + +
+ + +
+
+
+
+ )} + {createServer && ( +
+
+ +

Добавить SMTP-сервер

+
+ + + + + + + +
+ + +
+
+
+
+ )} +
+ ); +} + +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; \ No newline at end of file diff --git a/frontend/src/pages/UnsubscribedPage.js b/frontend/src/pages/UnsubscribedPage.js new file mode 100644 index 0000000..440d49d --- /dev/null +++ b/frontend/src/pages/UnsubscribedPage.js @@ -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 ( +
+
+ +
+

Отписались

+ {loading &&
Загрузка...
} + {error &&
{error}
} + {!loading && !error && ( + <> + + + + + + + + + + + + {subs.map(s => ( + + + + + + + + ))} + {subs.length === 0 && ( + + )} + +
IDEmailИмяДата отписки
{s.id}{s.email}{s.name}{s.unsubscribed_at ? new Date(s.unsubscribed_at).toLocaleString() : ''} + + +
Нет данных
+
+
+ + {Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => ( + + ))} + +
+
+ + )} + {editSub && ( +
+
+ +

Редактировать запись

+
+ + +
+ + +
+
+
+
+ )} + {createSub && ( +
+
+ +

Добавить запись

+
+ + +
+ + +
+
+
+
+ )} +
+ ); +} + +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; \ No newline at end of file diff --git a/frontend/src/pages/UsersPage.js b/frontend/src/pages/UsersPage.js new file mode 100644 index 0000000..ce222f3 --- /dev/null +++ b/frontend/src/pages/UsersPage.js @@ -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 ( +
+
+ +
+

Пользователи

+ {usersLoading &&
Загрузка...
} + {usersError &&
{usersError}
} + {!usersLoading && !usersError && ( + <> + + + + + + + + + + + + {users.map(u => ( + + + + + + + + ))} + {users.length === 0 && ( + + )} + +
IDEmailИмяРоль
{u.id}{u.email}{u.name}{getRoleName(u.role_id)} + + +
Нет данных
+
+
+ + {Array.from({ length: Math.ceil(usersTotal / PAGE_SIZE) || 1 }, (_, i) => ( + + ))} + +
+
+ + )} + {editUser && ( +
+
+ +

Редактировать пользователя

+
+ + + +
+ + +
+
+
+
+ )} + {createUser && ( +
+
+ +

Добавить пользователя

+
+ + + + +
+ + +
+
+
+
+ )} +
+ ); +} + +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; \ No newline at end of file diff --git a/mail-service/src/controllers/campaignController.js b/mail-service/src/controllers/campaignController.js index 543e586..1e11dae 100644 --- a/mail-service/src/controllers/campaignController.js +++ b/mail-service/src/controllers/campaignController.js @@ -11,8 +11,15 @@ export default { }, async getAll(req, res) { try { - const campaigns = await Campaign.findAll({ include: [EmailTemplateVersion, MailingGroup] }); - res.json(campaigns); + const limit = parseInt(req.query.limit) || 20; + 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) { res.status(500).json({ error: err.message }); } diff --git a/mail-service/src/controllers/deliveryLogController.js b/mail-service/src/controllers/deliveryLogController.js index 04696c4..36a8119 100644 --- a/mail-service/src/controllers/deliveryLogController.js +++ b/mail-service/src/controllers/deliveryLogController.js @@ -11,8 +11,15 @@ export default { }, async getAll(req, res) { try { - const logs = await DeliveryLog.findAll({ include: [Campaign, Subscriber] }); - res.json(logs); + const limit = parseInt(req.query.limit) || 20; + 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) { res.status(500).json({ error: err.message }); } diff --git a/mail-service/src/controllers/emailTemplateController.js b/mail-service/src/controllers/emailTemplateController.js index 9a44386..5d44306 100644 --- a/mail-service/src/controllers/emailTemplateController.js +++ b/mail-service/src/controllers/emailTemplateController.js @@ -11,8 +11,15 @@ export default { }, async getAll(req, res) { try { - const templates = await EmailTemplate.findAll({ include: EmailTemplateVersion }); - res.json(templates); + const limit = parseInt(req.query.limit) || 20; + 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) { res.status(500).json({ error: err.message }); } diff --git a/mail-service/src/controllers/groupSubscriberController.js b/mail-service/src/controllers/groupSubscriberController.js index 0e6ec36..297a87a 100644 --- a/mail-service/src/controllers/groupSubscriberController.js +++ b/mail-service/src/controllers/groupSubscriberController.js @@ -11,8 +11,15 @@ export default { }, async getAll(req, res) { try { - const groupSubscribers = await GroupSubscriber.findAll({ include: [MailingGroup, Subscriber] }); - res.json(groupSubscribers); + const limit = parseInt(req.query.limit) || 20; + 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) { res.status(500).json({ error: err.message }); } diff --git a/mail-service/src/controllers/mailingGroupController.js b/mail-service/src/controllers/mailingGroupController.js index 161bf3e..e51224c 100644 --- a/mail-service/src/controllers/mailingGroupController.js +++ b/mail-service/src/controllers/mailingGroupController.js @@ -11,8 +11,14 @@ export default { }, async getAll(req, res) { try { - const groups = await MailingGroup.findAll({ include: SmtpServer }); - res.json(groups); + const limit = parseInt(req.query.limit) || 20; + 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) { res.status(500).json({ error: err.message }); } diff --git a/mail-service/src/controllers/smtpServerController.js b/mail-service/src/controllers/smtpServerController.js index 352d0ca..559880a 100644 --- a/mail-service/src/controllers/smtpServerController.js +++ b/mail-service/src/controllers/smtpServerController.js @@ -1,8 +1,9 @@ -import { SmtpServer, MailingGroup } from '../models/index.js'; +import { SmtpServer } from '../models/index.js'; export default { async create(req, res) { try { + // group_id удалено const smtp = await SmtpServer.create(req.body); res.status(201).json(smtp); } catch (err) { @@ -11,15 +12,18 @@ export default { }, async getAll(req, res) { try { - const servers = await SmtpServer.findAll({ include: MailingGroup }); - res.json(servers); + const limit = parseInt(req.query.limit) || 20; + 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) { res.status(500).json({ error: err.message }); } }, async getById(req, res) { 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' }); res.json(smtp); } catch (err) { @@ -30,6 +34,7 @@ export default { try { const smtp = await SmtpServer.findByPk(req.params.id); if (!smtp) return res.status(404).json({ error: 'SmtpServer not found' }); + // group_id удалено await smtp.update(req.body); res.json(smtp); } catch (err) { diff --git a/mail-service/src/controllers/subscriberController.js b/mail-service/src/controllers/subscriberController.js index 9e989ef..34cdc3f 100644 --- a/mail-service/src/controllers/subscriberController.js +++ b/mail-service/src/controllers/subscriberController.js @@ -11,8 +11,10 @@ export default { }, async getAll(req, res) { try { - const subscribers = await Subscriber.findAll(); - res.json(subscribers); + const limit = parseInt(req.query.limit) || 20; + 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) { res.status(500).json({ error: err.message }); } diff --git a/mail-service/src/models/index.js b/mail-service/src/models/index.js index 1f7bd14..e38f92c 100644 --- a/mail-service/src/models/index.js +++ b/mail-service/src/models/index.js @@ -44,8 +44,7 @@ Campaign.belongsTo(MailingGroup, { foreignKey: 'group_id' }); DeliveryLog.belongsTo(Campaign, { foreignKey: 'campaign_id' }); DeliveryLog.belongsTo(Subscriber, { foreignKey: 'subscriber_id' }); -SmtpServer.belongsTo(MailingGroup, { foreignKey: 'group_id' }); -MailingGroup.hasMany(SmtpServer, { foreignKey: 'group_id' }); +// Удалены связи SmtpServer <-> MailingGroup по group_id // (связи с user_id только по полю, без внешнего ключа на User) diff --git a/mail-service/src/models/smtpServer.js b/mail-service/src/models/smtpServer.js index 24ce7d4..5d52429 100644 --- a/mail-service/src/models/smtpServer.js +++ b/mail-service/src/models/smtpServer.js @@ -4,7 +4,6 @@ export default (sequelize) => { const SmtpServer = sequelize.define('SmtpServer', { id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, user_id: { type: DataTypes.INTEGER, allowNull: false }, - group_id: { type: DataTypes.INTEGER, allowNull: true }, // связь с группой подписчиков name: { type: DataTypes.STRING, allowNull: false }, host: { type: DataTypes.STRING, allowNull: false }, port: { type: DataTypes.INTEGER, allowNull: false },