436 lines
16 KiB
JavaScript
436 lines
16 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { useUser } from '../context/UserContext';
|
|
import EditGroupModal from '../modals/EditGroupModal';
|
|
import CreateGroupModal from '../modals/CreateGroupModal';
|
|
import EditSubscriberModal from '../modals/EditSubscriberModal';
|
|
import CreateSubscriberModal from '../modals/CreateSubscriberModal';
|
|
import Paginator from '../components/Paginator';
|
|
|
|
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>
|
|
<Paginator
|
|
page={page}
|
|
total={total}
|
|
pageSize={PAGE_SIZE}
|
|
onPageChange={setPage}
|
|
/>
|
|
</>
|
|
)}
|
|
{editGroup && (
|
|
<EditGroupModal
|
|
isOpen={!!editGroup}
|
|
onClose={() => setEditGroup(null)}
|
|
group={editGroup}
|
|
loading={editLoading}
|
|
onChange={setEditGroup}
|
|
onSave={handleEditSave}
|
|
/>
|
|
)}
|
|
{createGroup && (
|
|
<CreateGroupModal
|
|
isOpen={!!createGroup}
|
|
onClose={() => setCreateGroup(null)}
|
|
group={createGroup}
|
|
loading={createLoading}
|
|
onChange={setCreateGroup}
|
|
onSave={handleCreateSave}
|
|
/>
|
|
)}
|
|
</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>
|
|
<Paginator
|
|
page={page}
|
|
total={total}
|
|
pageSize={PAGE_SIZE}
|
|
onPageChange={setPage}
|
|
/>
|
|
</>
|
|
)}
|
|
{editSub && (
|
|
<EditSubscriberModal
|
|
isOpen={!!editSub}
|
|
onClose={() => setEditSub(null)}
|
|
sub={editSub}
|
|
loading={editLoading}
|
|
onChange={setEditSub}
|
|
onSave={handleEditSave}
|
|
/>
|
|
)}
|
|
{createSub && (
|
|
<CreateSubscriberModal
|
|
isOpen={!!createSub}
|
|
onClose={() => setCreateSub(null)}
|
|
sub={createSub}
|
|
loading={createLoading}
|
|
onChange={setCreateSub}
|
|
onSave={handleCreateSave}
|
|
/>
|
|
)}
|
|
</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' };
|
|
|
|
export default GroupsPage;
|