486 lines
16 KiB
JavaScript
486 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';
|
||
import styles from '../styles/Common.module.css';
|
||
|
||
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 className={styles.container}>
|
||
<div className={styles.pageHeader}>
|
||
<h2 className={styles.pageTitle}>Подписчики и группы</h2>
|
||
<div className={styles.pageActions}>
|
||
<button
|
||
onClick={handleCreate}
|
||
className={`${styles.button} ${styles.buttonGradient}`}
|
||
>
|
||
+ Добавить группу
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||
{error && <div className={styles.error}>{error}</div>}
|
||
|
||
{!loading && !error && (
|
||
<>
|
||
<table className={styles.table}>
|
||
<thead className={styles.tableHeader}>
|
||
<tr>
|
||
<th className={styles.tableHeaderCell}>ID</th>
|
||
<th className={styles.tableHeaderCell}>Название</th>
|
||
<th className={styles.tableHeaderCell}>Описание</th>
|
||
<th className={styles.tableHeaderCell}>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{groups.map(g => (
|
||
<tr key={g.id} className={styles.tableRow} style={{ cursor: 'pointer' }} onClick={() => setSelectedGroup(g)}>
|
||
<td className={styles.tableCell}>{g.id}</td>
|
||
<td className={styles.tableCell}>{g.name}</td>
|
||
<td className={styles.tableCell}>{g.description}</td>
|
||
<td className={styles.tableCellActions}>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleEdit(g); }}
|
||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||
style={{ marginRight: '8px' }}
|
||
>
|
||
Редактировать
|
||
</button>
|
||
<button
|
||
onClick={(e) => { e.stopPropagation(); handleDelete(g.id); }}
|
||
className={`${styles.button} ${styles.buttonDanger}`}
|
||
disabled={deleteLoading === g.id}
|
||
>
|
||
{deleteLoading === g.id ? 'Удаление...' : 'Удалить'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{groups.length === 0 && (
|
||
<tr>
|
||
<td colSpan={4} className={styles.emptyState}>
|
||
<div className={styles.emptyStateIcon}>👥</div>
|
||
<div className={styles.emptyStateTitle}>Нет групп</div>
|
||
<div className={styles.emptyStateText}>Создайте первую группу для организации подписчиков</div>
|
||
</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 className={styles.container}>
|
||
<button
|
||
onClick={onBack}
|
||
className={`${styles.button} ${styles.buttonSecondary}`}
|
||
style={{ marginBottom: 18 }}
|
||
>
|
||
← Назад к группам
|
||
</button>
|
||
|
||
<div className={styles.pageHeader}>
|
||
<h2 className={styles.pageTitle}>Подписчики группы: {group.name}</h2>
|
||
<div className={styles.pageActions}>
|
||
<button
|
||
onClick={handleCreate}
|
||
className={`${styles.button} ${styles.buttonGradient}`}
|
||
>
|
||
+ Добавить подписчика
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{loading && <div className={styles.loading}>Загрузка...</div>}
|
||
{error && <div className={styles.error}>{error}</div>}
|
||
|
||
{!loading && !error && (
|
||
<>
|
||
<table className={styles.table}>
|
||
<thead className={styles.tableHeader}>
|
||
<tr>
|
||
<th className={styles.tableHeaderCell}>ID</th>
|
||
<th className={styles.tableHeaderCell}>Email</th>
|
||
<th className={styles.tableHeaderCell}>Имя</th>
|
||
<th className={styles.tableHeaderCell}>Статус</th>
|
||
<th className={styles.tableHeaderCell}>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{subs.map(gs => (
|
||
<tr key={gs.id} className={styles.tableRow}>
|
||
<td className={styles.tableCell}>{gs.Subscriber?.id || ''}</td>
|
||
<td className={styles.tableCell}>{gs.Subscriber?.email || ''}</td>
|
||
<td className={styles.tableCell}>{gs.Subscriber?.name || ''}</td>
|
||
<td className={styles.tableCell}>{gs.Subscriber?.status || ''}</td>
|
||
<td className={styles.tableCellActions}>
|
||
<button
|
||
onClick={() => handleEdit(gs)}
|
||
className={`${styles.button} ${styles.buttonPrimary}`}
|
||
style={{ marginRight: '8px' }}
|
||
>
|
||
Редактировать
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(gs.id)}
|
||
className={`${styles.button} ${styles.buttonDanger}`}
|
||
disabled={deleteLoading === gs.id}
|
||
>
|
||
{deleteLoading === gs.id ? 'Удаление...' : 'Удалить'}
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
{subs.length === 0 && (
|
||
<tr>
|
||
<td colSpan={5} className={styles.emptyState}>
|
||
<div className={styles.emptyStateIcon}>📧</div>
|
||
<div className={styles.emptyStateTitle}>Нет подписчиков</div>
|
||
<div className={styles.emptyStateText}>В этой группе пока нет подписчиков</div>
|
||
</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>
|
||
);
|
||
}
|
||
|
||
export default GroupsPage;
|