version
This commit is contained in:
parent
6e89deb3f5
commit
63eb19fd02
47
frontend/src/modals/CreateTemplateVersionModal.js
Normal file
47
frontend/src/modals/CreateTemplateVersionModal.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/TemplateModal.module.css';
|
||||||
|
|
||||||
|
export default function CreateTemplateVersionModal({ isOpen, onClose, loading, onSave, initial }) {
|
||||||
|
const [subject, setSubject] = useState(initial?.subject || '');
|
||||||
|
const [bodyHtml, setBodyHtml] = useState(initial?.body_html || '');
|
||||||
|
const [bodyText, setBodyText] = useState(initial?.body_text || '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initial) {
|
||||||
|
setSubject(initial.subject || '');
|
||||||
|
setBodyHtml(initial.body_html || '');
|
||||||
|
setBodyText(initial.body_text || '');
|
||||||
|
} else {
|
||||||
|
setSubject('');
|
||||||
|
setBodyHtml('');
|
||||||
|
setBodyText('');
|
||||||
|
}
|
||||||
|
}, [initial, isOpen]);
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({ subject, body_html: bodyHtml, body_text: bodyText });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>{initial ? 'Редактировать версию' : 'Добавить версию шаблона'}</h3>
|
||||||
|
<form onSubmit={handleSubmit} className={styles.form}>
|
||||||
|
<label className={styles.label}>Тема письма
|
||||||
|
<input type="text" value={subject} onChange={e => setSubject(e.target.value)} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>HTML-версия
|
||||||
|
<textarea value={bodyHtml} onChange={e => setBodyHtml(e.target.value)} className={styles.input} rows={4} placeholder="<h1>Заголовок</h1>..." />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Текстовая версия
|
||||||
|
<textarea value={bodyText} onChange={e => setBodyText(e.target.value)} className={styles.input} rows={4} placeholder="Текст письма..." />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? (initial ? 'Сохранение...' : 'Создание...') : (initial ? 'Сохранить' : 'Создать')}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
frontend/src/pages/TemplateVersionsPage.js
Normal file
207
frontend/src/pages/TemplateVersionsPage.js
Normal file
@ -0,0 +1,207 @@
|
|||||||
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
|
import CreateTemplateVersionModal from '../modals/CreateTemplateVersionModal';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
export default function TemplateVersionsPage({ template, onBack, token }) {
|
||||||
|
const [versions, setVersions] = useState([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [createVersionModal, setCreateVersionModal] = useState(false);
|
||||||
|
const [createVersionLoading, setCreateVersionLoading] = useState(false);
|
||||||
|
const [editVersion, setEditVersion] = useState(null);
|
||||||
|
const [editVersionLoading, setEditVersionLoading] = useState(false);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(null);
|
||||||
|
|
||||||
|
const fetchVersions = useCallback(async (page) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const offset = (page - 1) * PAGE_SIZE;
|
||||||
|
const res = await fetch(`/api/mail/email-template-versions?template_id=${template.id}&limit=${PAGE_SIZE}&offset=${offset}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
let rows = [];
|
||||||
|
let count = 0;
|
||||||
|
if (res.ok && Array.isArray(data)) {
|
||||||
|
rows = data.filter(v => v.template_id === template.id);
|
||||||
|
count = rows.length;
|
||||||
|
} else if (res.ok && Array.isArray(data.rows)) {
|
||||||
|
rows = data.rows.filter(v => v.template_id === template.id);
|
||||||
|
count = data.count || rows.length;
|
||||||
|
}
|
||||||
|
setVersions(rows);
|
||||||
|
setTotal(count);
|
||||||
|
} catch (e) {
|
||||||
|
setError('Ошибка сети');
|
||||||
|
setVersions([]);
|
||||||
|
setTotal(0);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [template.id, token]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVersions(page);
|
||||||
|
}, [fetchVersions, page]);
|
||||||
|
|
||||||
|
const handleAddVersion = () => setCreateVersionModal(true);
|
||||||
|
const handleCreateVersionSave = async (versionData) => {
|
||||||
|
setCreateVersionLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/mail/email-template-versions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ ...versionData, template_id: template.id })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || 'Ошибка создания версии');
|
||||||
|
} else {
|
||||||
|
setCreateVersionModal(false);
|
||||||
|
fetchVersions(page);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка сети');
|
||||||
|
} finally {
|
||||||
|
setCreateVersionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Edit version ---
|
||||||
|
const handleEditVersion = (version) => setEditVersion(version);
|
||||||
|
const handleEditVersionSave = async (data) => {
|
||||||
|
setEditVersionLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/mail/email-template-versions/${editVersion.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
subject: data.subject,
|
||||||
|
body_html: data.body_html,
|
||||||
|
body_text: data.body_text
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const resp = await res.json();
|
||||||
|
alert(resp.error || 'Ошибка обновления версии');
|
||||||
|
} else {
|
||||||
|
setEditVersion(null);
|
||||||
|
fetchVersions(page);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка сети');
|
||||||
|
} finally {
|
||||||
|
setEditVersionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Delete version ---
|
||||||
|
const handleDeleteVersion = async (id) => {
|
||||||
|
if (!window.confirm('Удалить эту версию шаблона?')) return;
|
||||||
|
setDeleteLoading(id);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/mail/email-template-versions/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {}
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.error || 'Ошибка удаления');
|
||||||
|
} else {
|
||||||
|
fetchVersions(page);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка сети');
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button onClick={onBack} style={{ marginBottom: 18, background: 'none', border: 'none', color: '#6366f1', fontWeight: 600, fontSize: 16, cursor: 'pointer' }}>← Назад к шаблонам</button>
|
||||||
|
<h2>Версии шаблона: {template.name}</h2>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||||
|
<button onClick={handleAddVersion} 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}>Версия</th>
|
||||||
|
<th style={thStyle}>Тема</th>
|
||||||
|
<th style={thStyle}>HTML</th>
|
||||||
|
<th style={thStyle}>Текст</th>
|
||||||
|
<th style={thStyle}></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{versions.map(v => (
|
||||||
|
<tr key={v.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
||||||
|
<td style={tdStyle}>{v.id}</td>
|
||||||
|
<td style={tdStyle}>{v.version_number}</td>
|
||||||
|
<td style={tdStyle}>{v.subject}</td>
|
||||||
|
<td style={tdStyle}>{v.body_html ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
|
||||||
|
<td style={tdStyle}>{v.body_text ? <span style={{ color: '#10b981' }}>Есть</span> : <span style={{ color: '#ef4444' }}>Нет</span>}</td>
|
||||||
|
<td style={tdStyle}>
|
||||||
|
<button onClick={() => handleEditVersion(v)} style={btnStyle}>Редактировать</button>
|
||||||
|
<button onClick={() => handleDeleteVersion(v.id)} style={btnStyle} disabled={deleteLoading === v.id}>
|
||||||
|
{deleteLoading === v.id ? 'Удаление...' : 'Удалить'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{versions.length === 0 && (
|
||||||
|
<tr><td colSpan={6} style={{ textAlign: 'center', color: '#9ca3af', padding: 24 }}>Нет версий</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/* Пагинация если нужно */}
|
||||||
|
{total > PAGE_SIZE && (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<button onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page === 1} style={btnStyle}>Назад</button>
|
||||||
|
<span style={{ margin: '0 12px' }}>Страница {page}</span>
|
||||||
|
<button onClick={() => setPage(p => p + 1)} disabled={page * PAGE_SIZE >= total} style={btnStyle}>Вперёд</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{createVersionModal && (
|
||||||
|
<CreateTemplateVersionModal
|
||||||
|
isOpen={createVersionModal}
|
||||||
|
onClose={() => setCreateVersionModal(false)}
|
||||||
|
loading={createVersionLoading}
|
||||||
|
onSave={handleCreateVersionSave}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{editVersion && (
|
||||||
|
<CreateTemplateVersionModal
|
||||||
|
isOpen={!!editVersion}
|
||||||
|
onClose={() => setEditVersion(null)}
|
||||||
|
loading={editVersionLoading}
|
||||||
|
onSave={handleEditVersionSave}
|
||||||
|
initial={editVersion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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 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' };
|
||||||
@ -25,9 +25,19 @@ app.use('/api/mail', routes);
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
let isQueueFilling = false;
|
||||||
|
|
||||||
// Периодически заполняем очередь для scheduled кампаний
|
// Периодически заполняем очередь для scheduled кампаний
|
||||||
setInterval(() => {
|
setInterval(async () => {
|
||||||
processScheduledCampaigns().catch(err => console.error('Queue fill error:', err));
|
if (isQueueFilling) return;
|
||||||
|
isQueueFilling = true;
|
||||||
|
try {
|
||||||
|
await processScheduledCampaigns();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Queue fill error:', err);
|
||||||
|
} finally {
|
||||||
|
isQueueFilling = false;
|
||||||
|
}
|
||||||
}, 60 * 1000); // раз в минуту
|
}, 60 * 1000); // раз в минуту
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user