CRUD
This commit is contained in:
parent
814e3a25e6
commit
bee42c5b16
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
372
frontend/src/pages/CampaignPage.js
Normal file
372
frontend/src/pages/CampaignPage.js
Normal 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;
|
||||||
@ -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;
|
||||||
115
frontend/src/pages/DeliveryHistoryPage.js
Normal file
115
frontend/src/pages/DeliveryHistoryPage.js
Normal 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;
|
||||||
258
frontend/src/pages/EmailTemplatesPage.js
Normal file
258
frontend/src/pages/EmailTemplatesPage.js
Normal 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;
|
||||||
547
frontend/src/pages/GroupsPage.js
Normal file
547
frontend/src/pages/GroupsPage.js
Normal 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;
|
||||||
@ -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('Ошибка сети');
|
||||||
|
|||||||
314
frontend/src/pages/SmtpServersPage.js
Normal file
314
frontend/src/pages/SmtpServersPage.js
Normal 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;
|
||||||
268
frontend/src/pages/UnsubscribedPage.js
Normal file
268
frontend/src/pages/UnsubscribedPage.js
Normal 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;
|
||||||
318
frontend/src/pages/UsersPage.js
Normal file
318
frontend/src/pages/UsersPage.js
Normal 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;
|
||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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 },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user