style and modals
This commit is contained in:
parent
bee42c5b16
commit
f3aeaad948
37
frontend/src/components/Paginator.js
Normal file
37
frontend/src/components/Paginator.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from '../styles/Paginator.module.css';
|
||||||
|
|
||||||
|
export default function Paginator({ page, total, pageSize, onPageChange, className }) {
|
||||||
|
const pageCount = Math.ceil(total / pageSize) || 1;
|
||||||
|
if (pageCount <= 1) return null;
|
||||||
|
return (
|
||||||
|
<div className={className || styles.wrapper}>
|
||||||
|
<button
|
||||||
|
className={styles.btn}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
disabled={page <= 1}
|
||||||
|
style={{ opacity: page <= 1 ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</button>
|
||||||
|
{Array.from({ length: pageCount }, (_, i) => (
|
||||||
|
<button
|
||||||
|
key={i + 1}
|
||||||
|
className={page === i + 1 ? styles.pageActive : styles.page}
|
||||||
|
onClick={() => onPageChange(i + 1)}
|
||||||
|
disabled={page === i + 1}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
className={styles.btn}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= pageCount}
|
||||||
|
style={{ opacity: page >= pageCount ? 0.5 : 1 }}
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/modals/CreateCampaignModal.js
Normal file
45
frontend/src/modals/CreateCampaignModal.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/CampaignModal.module.css';
|
||||||
|
|
||||||
|
export default function CreateCampaignModal({ isOpen, onClose, campaign, groups, versions, loading, onChange, onSave, getVersionName }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Добавить кампанию</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Группа
|
||||||
|
<select value={campaign.group_id} onChange={e => onChange({ ...campaign, group_id: Number(e.target.value) })} required className={styles.input}>
|
||||||
|
{groups.map(g => (
|
||||||
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Версия шаблона
|
||||||
|
<select value={campaign.template_version_id} onChange={e => onChange({ ...campaign, template_version_id: Number(e.target.value) })} required className={styles.input}>
|
||||||
|
{versions.map(v => (
|
||||||
|
<option key={v.id} value={v.id}>{getVersionName(v.id)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Тема
|
||||||
|
<input type="text" value={campaign.subject_override || ''} onChange={e => onChange({ ...campaign, subject_override: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Статус
|
||||||
|
<select value={campaign.status} onChange={e => onChange({ ...campaign, status: e.target.value })} required className={styles.input}>
|
||||||
|
<option value="draft">Черновик</option>
|
||||||
|
<option value="scheduled">Запланировано</option>
|
||||||
|
<option value="sent">Отправлено</option>
|
||||||
|
<option value="failed">Ошибка</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Запланировано на
|
||||||
|
<input type="datetime-local" value={campaign.scheduled_at} onChange={e => onChange({ ...campaign, scheduled_at: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Создание...' : 'Создать'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/modals/CreateGroupModal.js
Normal file
23
frontend/src/modals/CreateGroupModal.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/GroupModal.module.css';
|
||||||
|
|
||||||
|
export default function CreateGroupModal({ isOpen, onClose, group, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Добавить группу</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Название
|
||||||
|
<input type="text" value={group.name} onChange={e => onChange({ ...group, name: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Описание
|
||||||
|
<input type="text" value={group.description || ''} onChange={e => onChange({ ...group, description: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Создание...' : 'Создать'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/modals/CreateSmtpModal.js
Normal file
41
frontend/src/modals/CreateSmtpModal.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/SmtpModal.module.css';
|
||||||
|
|
||||||
|
export default function CreateSmtpModal({ isOpen, onClose, server, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Добавить SMTP-сервер</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Название
|
||||||
|
<input type="text" value={server.name} onChange={e => onChange({ ...server, name: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Host
|
||||||
|
<input type="text" value={server.host} onChange={e => onChange({ ...server, host: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Port
|
||||||
|
<input type="number" value={server.port} onChange={e => onChange({ ...server, port: Number(e.target.value) })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Secure
|
||||||
|
<select value={server.secure ? '1' : '0'} onChange={e => onChange({ ...server, secure: e.target.value === '1' })} className={styles.input}>
|
||||||
|
<option value="0">Нет</option>
|
||||||
|
<option value="1">Да</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Пользователь
|
||||||
|
<input type="text" value={server.username} onChange={e => onChange({ ...server, username: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Пароль
|
||||||
|
<input type="password" value={server.password} onChange={e => onChange({ ...server, password: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Отправитель (from_email)
|
||||||
|
<input type="email" value={server.from_email} onChange={e => onChange({ ...server, from_email: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Создание...' : 'Создать'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/modals/CreateSubscriberModal.js
Normal file
30
frontend/src/modals/CreateSubscriberModal.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/SubscriberModal.module.css';
|
||||||
|
|
||||||
|
export default function CreateSubscriberModal({ isOpen, onClose, sub, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Добавить подписчика</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Email
|
||||||
|
<input type="email" value={sub.email} onChange={e => onChange({ ...sub, email: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Имя
|
||||||
|
<input type="text" value={sub.name || ''} onChange={e => onChange({ ...sub, name: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Статус
|
||||||
|
<select value={sub.status} onChange={e => onChange({ ...sub, status: e.target.value })} required className={styles.input}>
|
||||||
|
<option value="active">Активен</option>
|
||||||
|
<option value="unsubscribed">Отписан</option>
|
||||||
|
<option value="bounced">Ошибка</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Создание...' : 'Создать'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/modals/CreateTemplateModal.js
Normal file
20
frontend/src/modals/CreateTemplateModal.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/TemplateModal.module.css';
|
||||||
|
|
||||||
|
export default function CreateTemplateModal({ isOpen, onClose, template, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Добавить шаблон</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Название
|
||||||
|
<input type="text" value={template.name} onChange={e => onChange({ ...template, name: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Создание...' : 'Создать'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/modals/CreateUnsubModal.js
Normal file
23
frontend/src/modals/CreateUnsubModal.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/UnsubModal.module.css';
|
||||||
|
|
||||||
|
export default function CreateUnsubModal({ isOpen, onClose, sub, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Добавить запись</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Email
|
||||||
|
<input type="email" value={sub.email} onChange={e => onChange({ ...sub, email: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Имя
|
||||||
|
<input type="text" value={sub.name || ''} onChange={e => onChange({ ...sub, name: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Создание...' : 'Создать'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
frontend/src/modals/CreateUserModal.js
Normal file
33
frontend/src/modals/CreateUserModal.js
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/UserModal.module.css';
|
||||||
|
|
||||||
|
export default function CreateUserModal({ isOpen, onClose, user, roles, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Добавить пользователя</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Email
|
||||||
|
<input type="email" value={user.email} onChange={e => onChange({ ...user, email: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Имя
|
||||||
|
<input type="text" value={user.name} onChange={e => onChange({ ...user, name: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Роль
|
||||||
|
<select value={user.role_id} onChange={e => onChange({ ...user, role_id: Number(e.target.value) })} required className={styles.input}>
|
||||||
|
{roles.map(role => (
|
||||||
|
<option key={role.id} value={role.id}>{role.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Пароль
|
||||||
|
<input type="password" value={user.password} onChange={e => onChange({ ...user, password: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Создание...' : 'Создать'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
frontend/src/modals/EditCampaignModal.js
Normal file
45
frontend/src/modals/EditCampaignModal.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/CampaignModal.module.css';
|
||||||
|
|
||||||
|
export default function EditCampaignModal({ isOpen, onClose, campaign, groups, versions, loading, onChange, onSave, getVersionName }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Редактировать кампанию</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Группа
|
||||||
|
<select value={campaign.group_id} onChange={e => onChange({ ...campaign, group_id: Number(e.target.value) })} required className={styles.input}>
|
||||||
|
{groups.map(g => (
|
||||||
|
<option key={g.id} value={g.id}>{g.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Версия шаблона
|
||||||
|
<select value={campaign.template_version_id} onChange={e => onChange({ ...campaign, template_version_id: Number(e.target.value) })} required className={styles.input}>
|
||||||
|
{versions.map(v => (
|
||||||
|
<option key={v.id} value={v.id}>{getVersionName(v.id)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Тема
|
||||||
|
<input type="text" value={campaign.subject_override || ''} onChange={e => onChange({ ...campaign, subject_override: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Статус
|
||||||
|
<select value={campaign.status} onChange={e => onChange({ ...campaign, status: e.target.value })} required className={styles.input}>
|
||||||
|
<option value="draft">Черновик</option>
|
||||||
|
<option value="scheduled">Запланировано</option>
|
||||||
|
<option value="sent">Отправлено</option>
|
||||||
|
<option value="failed">Ошибка</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Запланировано на
|
||||||
|
<input type="datetime-local" value={campaign.scheduled_at ? new Date(campaign.scheduled_at).toISOString().slice(0,16) : ''} onChange={e => onChange({ ...campaign, scheduled_at: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/modals/EditGroupModal.js
Normal file
23
frontend/src/modals/EditGroupModal.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/GroupModal.module.css';
|
||||||
|
|
||||||
|
export default function EditGroupModal({ isOpen, onClose, group, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Редактировать группу</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Название
|
||||||
|
<input type="text" value={group.name} onChange={e => onChange({ ...group, name: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Описание
|
||||||
|
<input type="text" value={group.description || ''} onChange={e => onChange({ ...group, description: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
frontend/src/modals/EditSmtpModal.js
Normal file
41
frontend/src/modals/EditSmtpModal.js
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/SmtpModal.module.css';
|
||||||
|
|
||||||
|
export default function EditSmtpModal({ isOpen, onClose, server, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Редактировать SMTP-сервер</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Название
|
||||||
|
<input type="text" value={server.name} onChange={e => onChange({ ...server, name: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Host
|
||||||
|
<input type="text" value={server.host} onChange={e => onChange({ ...server, host: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Port
|
||||||
|
<input type="number" value={server.port} onChange={e => onChange({ ...server, port: Number(e.target.value) })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Secure
|
||||||
|
<select value={server.secure ? '1' : '0'} onChange={e => onChange({ ...server, secure: e.target.value === '1' })} className={styles.input}>
|
||||||
|
<option value="0">Нет</option>
|
||||||
|
<option value="1">Да</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Пользователь
|
||||||
|
<input type="text" value={server.username} onChange={e => onChange({ ...server, username: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Пароль
|
||||||
|
<input type="password" value={server.password} onChange={e => onChange({ ...server, password: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Отправитель (from_email)
|
||||||
|
<input type="email" value={server.from_email} onChange={e => onChange({ ...server, from_email: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/modals/EditSubscriberModal.js
Normal file
30
frontend/src/modals/EditSubscriberModal.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/SubscriberModal.module.css';
|
||||||
|
|
||||||
|
export default function EditSubscriberModal({ isOpen, onClose, sub, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Редактировать подписчика</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Email
|
||||||
|
<input type="email" value={sub.email} onChange={e => onChange({ ...sub, email: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Имя
|
||||||
|
<input type="text" value={sub.name || ''} onChange={e => onChange({ ...sub, name: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Статус
|
||||||
|
<select value={sub.status} onChange={e => onChange({ ...sub, status: e.target.value })} required className={styles.input}>
|
||||||
|
<option value="active">Активен</option>
|
||||||
|
<option value="unsubscribed">Отписан</option>
|
||||||
|
<option value="bounced">Ошибка</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
frontend/src/modals/EditTemplateModal.js
Normal file
20
frontend/src/modals/EditTemplateModal.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/TemplateModal.module.css';
|
||||||
|
|
||||||
|
export default function EditTemplateModal({ isOpen, onClose, template, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Редактировать шаблон</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Название
|
||||||
|
<input type="text" value={template.name} onChange={e => onChange({ ...template, name: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/modals/EditUnsubModal.js
Normal file
23
frontend/src/modals/EditUnsubModal.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/UnsubModal.module.css';
|
||||||
|
|
||||||
|
export default function EditUnsubModal({ isOpen, onClose, sub, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Редактировать запись</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Email
|
||||||
|
<input type="email" value={sub.email} onChange={e => onChange({ ...sub, email: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Имя
|
||||||
|
<input type="text" value={sub.name || ''} onChange={e => onChange({ ...sub, name: e.target.value })} className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
frontend/src/modals/EditUserModal.js
Normal file
30
frontend/src/modals/EditUserModal.js
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from './Modal';
|
||||||
|
import styles from '../styles/UserModal.module.css';
|
||||||
|
|
||||||
|
export default function EditUserModal({ isOpen, onClose, user, roles, loading, onChange, onSave }) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onClose={onClose} disabled={loading}>
|
||||||
|
<h3 className={styles.title}>Редактировать пользователя</h3>
|
||||||
|
<form onSubmit={onSave} className={styles.form}>
|
||||||
|
<label className={styles.label}>Email
|
||||||
|
<input type="email" value={user.email} onChange={e => onChange({ ...user, email: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Имя
|
||||||
|
<input type="text" value={user.name} onChange={e => onChange({ ...user, name: e.target.value })} required className={styles.input} />
|
||||||
|
</label>
|
||||||
|
<label className={styles.label}>Роль
|
||||||
|
<select value={user.role_id} onChange={e => onChange({ ...user, role_id: Number(e.target.value) })} required className={styles.input}>
|
||||||
|
{roles.map(role => (
|
||||||
|
<option key={role.id} value={role.id}>{role.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<button type="submit" disabled={loading} className={styles.saveBtn}>{loading ? 'Сохранение...' : 'Сохранить'}</button>
|
||||||
|
<button type="button" onClick={onClose} disabled={loading} className={styles.cancelBtn}>Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
frontend/src/modals/Modal.js
Normal file
22
frontend/src/modals/Modal.js
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import styles from '../styles/Modal.module.css';
|
||||||
|
|
||||||
|
export default function Modal({ isOpen, onClose, children, disabled }) {
|
||||||
|
if (!isOpen) return null;
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.modal}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.closeBtn}
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label="Закрыть"
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useUser } from '../context/UserContext';
|
import { useUser } from '../context/UserContext';
|
||||||
|
import EditCampaignModal from '../modals/EditCampaignModal';
|
||||||
|
import CreateCampaignModal from '../modals/CreateCampaignModal';
|
||||||
|
import Paginator from '../components/Paginator';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -228,126 +231,39 @@ function CampaignPage() {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Paginator
|
||||||
<div style={paginatorWrapperStyle}>
|
page={page}
|
||||||
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}>←</button>
|
total={total}
|
||||||
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
|
pageSize={PAGE_SIZE}
|
||||||
<button
|
onPageChange={setPage}
|
||||||
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 && (
|
{editCampaign && (
|
||||||
<div style={modalOverlayStyle}>
|
<EditCampaignModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!editCampaign}
|
||||||
<button
|
onClose={() => setEditCampaign(null)}
|
||||||
type="button"
|
campaign={editCampaign}
|
||||||
onClick={() => setEditCampaign(null)}
|
groups={groups}
|
||||||
style={closeBtnStyle}
|
versions={versions}
|
||||||
aria-label="Закрыть"
|
loading={editLoading}
|
||||||
disabled={editLoading}
|
onChange={setEditCampaign}
|
||||||
>
|
onSave={handleEditSave}
|
||||||
×
|
getVersionName={getVersionName}
|
||||||
</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 && (
|
{createCampaign && (
|
||||||
<div style={modalOverlayStyle}>
|
<CreateCampaignModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!createCampaign}
|
||||||
<button
|
onClose={() => setCreateCampaign(null)}
|
||||||
type="button"
|
campaign={createCampaign}
|
||||||
onClick={() => setCreateCampaign(null)}
|
groups={groups}
|
||||||
style={closeBtnStyle}
|
versions={versions}
|
||||||
aria-label="Закрыть"
|
loading={createLoading}
|
||||||
disabled={createLoading}
|
onChange={setCreateCampaign}
|
||||||
>
|
onSave={handleCreateSave}
|
||||||
×
|
getVersionName={getVersionName}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -364,9 +280,5 @@ const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border
|
|||||||
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 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 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 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;
|
export default CampaignPage;
|
||||||
@ -1,5 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useUser } from '../context/UserContext';
|
import { useUser } from '../context/UserContext';
|
||||||
|
import Paginator from '../components/Paginator';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -80,25 +81,12 @@ function DeliveryHistoryPage() {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Paginator
|
||||||
<div style={paginatorWrapperStyle}>
|
page={page}
|
||||||
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}>←</button>
|
total={total}
|
||||||
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
|
pageSize={PAGE_SIZE}
|
||||||
<button
|
onPageChange={setPage}
|
||||||
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>
|
</div>
|
||||||
@ -107,9 +95,5 @@ function DeliveryHistoryPage() {
|
|||||||
|
|
||||||
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
const thStyle = { padding: '10px 16px', textAlign: 'left', fontWeight: 600, borderBottom: '2px solid #e5e7eb', background: '#f3f4f6' };
|
||||||
const tdStyle = { padding: '10px 16px', background: '#fff' };
|
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;
|
export default DeliveryHistoryPage;
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useUser } from '../context/UserContext';
|
import { useUser } from '../context/UserContext';
|
||||||
|
import EditTemplateModal from '../modals/EditTemplateModal';
|
||||||
|
import CreateTemplateModal from '../modals/CreateTemplateModal';
|
||||||
|
import Paginator from '../components/Paginator';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -164,76 +167,33 @@ function EmailTemplatesPage() {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Paginator
|
||||||
<div style={paginatorWrapperStyle}>
|
page={page}
|
||||||
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}>←</button>
|
total={total}
|
||||||
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
|
pageSize={PAGE_SIZE}
|
||||||
<button
|
onPageChange={setPage}
|
||||||
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 && (
|
{editTemplate && (
|
||||||
<div style={modalOverlayStyle}>
|
<EditTemplateModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!editTemplate}
|
||||||
<button
|
onClose={() => setEditTemplate(null)}
|
||||||
type="button"
|
template={editTemplate}
|
||||||
onClick={() => setEditTemplate(null)}
|
loading={editLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setEditTemplate}
|
||||||
aria-label="Закрыть"
|
onSave={handleEditSave}
|
||||||
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 && (
|
{createTemplate && (
|
||||||
<div style={modalOverlayStyle}>
|
<CreateTemplateModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!createTemplate}
|
||||||
<button
|
onClose={() => setCreateTemplate(null)}
|
||||||
type="button"
|
template={createTemplate}
|
||||||
onClick={() => setCreateTemplate(null)}
|
loading={createLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setCreateTemplate}
|
||||||
aria-label="Закрыть"
|
onSave={handleCreateSave}
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -249,10 +209,6 @@ const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: '
|
|||||||
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 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 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 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' };
|
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;
|
export default EmailTemplatesPage;
|
||||||
@ -1,5 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useUser } from '../context/UserContext';
|
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;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -171,82 +176,33 @@ function GroupsPage() {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Paginator
|
||||||
<div style={paginatorWrapperStyle}>
|
page={page}
|
||||||
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}>←</button>
|
total={total}
|
||||||
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
|
pageSize={PAGE_SIZE}
|
||||||
<button
|
onPageChange={setPage}
|
||||||
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 && (
|
{editGroup && (
|
||||||
<div style={modalOverlayStyle}>
|
<EditGroupModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!editGroup}
|
||||||
<button
|
onClose={() => setEditGroup(null)}
|
||||||
type="button"
|
group={editGroup}
|
||||||
onClick={() => setEditGroup(null)}
|
loading={editLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setEditGroup}
|
||||||
aria-label="Закрыть"
|
onSave={handleEditSave}
|
||||||
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 && (
|
{createGroup && (
|
||||||
<div style={modalOverlayStyle}>
|
<CreateGroupModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!createGroup}
|
||||||
<button
|
onClose={() => setCreateGroup(null)}
|
||||||
type="button"
|
group={createGroup}
|
||||||
onClick={() => setCreateGroup(null)}
|
loading={createLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setCreateGroup}
|
||||||
aria-label="Закрыть"
|
onSave={handleCreateSave}
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -433,96 +389,33 @@ function SubscribersInGroupPage({ group, onBack, token }) {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Paginator
|
||||||
<div style={paginatorWrapperStyle}>
|
page={page}
|
||||||
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}>←</button>
|
total={total}
|
||||||
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
|
pageSize={PAGE_SIZE}
|
||||||
<button
|
onPageChange={setPage}
|
||||||
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 && (
|
{editSub && (
|
||||||
<div style={modalOverlayStyle}>
|
<EditSubscriberModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!editSub}
|
||||||
<button
|
onClose={() => setEditSub(null)}
|
||||||
type="button"
|
sub={editSub}
|
||||||
onClick={() => setEditSub(null)}
|
loading={editLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setEditSub}
|
||||||
aria-label="Закрыть"
|
onSave={handleEditSave}
|
||||||
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 && (
|
{createSub && (
|
||||||
<div style={modalOverlayStyle}>
|
<CreateSubscriberModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!createSub}
|
||||||
<button
|
onClose={() => setCreateSub(null)}
|
||||||
type="button"
|
sub={createSub}
|
||||||
onClick={() => setCreateSub(null)}
|
loading={createLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setCreateSub}
|
||||||
aria-label="Закрыть"
|
onSave={handleCreateSave}
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -539,9 +432,5 @@ const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border
|
|||||||
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 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 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 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;
|
export default GroupsPage;
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useUser } from '../context/UserContext';
|
import { useUser } from '../context/UserContext';
|
||||||
|
import EditSmtpModal from '../modals/EditSmtpModal';
|
||||||
|
import CreateSmtpModal from '../modals/CreateSmtpModal';
|
||||||
|
import Paginator from '../components/Paginator';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -178,118 +181,33 @@ function SmtpServersPage() {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Paginator
|
||||||
<div style={paginatorWrapperStyle}>
|
page={page}
|
||||||
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}>←</button>
|
total={total}
|
||||||
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
|
pageSize={PAGE_SIZE}
|
||||||
<button
|
onPageChange={setPage}
|
||||||
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 && (
|
{editServer && (
|
||||||
<div style={modalOverlayStyle}>
|
<EditSmtpModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!editServer}
|
||||||
<button
|
onClose={() => setEditServer(null)}
|
||||||
type="button"
|
server={editServer}
|
||||||
onClick={() => setEditServer(null)}
|
loading={editLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setEditServer}
|
||||||
aria-label="Закрыть"
|
onSave={handleEditSave}
|
||||||
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 && (
|
{createServer && (
|
||||||
<div style={modalOverlayStyle}>
|
<CreateSmtpModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!createServer}
|
||||||
<button
|
onClose={() => setCreateServer(null)}
|
||||||
type="button"
|
server={createServer}
|
||||||
onClick={() => setCreateServer(null)}
|
loading={createLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setCreateServer}
|
||||||
aria-label="Закрыть"
|
onSave={handleCreateSave}
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -305,10 +223,6 @@ const labelStyle = { fontWeight: 500, color: '#374151', fontSize: 15, display: '
|
|||||||
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 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 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 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' };
|
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;
|
export default SmtpServersPage;
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useUser } from '../context/UserContext';
|
import { useUser } from '../context/UserContext';
|
||||||
|
import EditUnsubModal from '../modals/EditUnsubModal';
|
||||||
|
import CreateUnsubModal from '../modals/CreateUnsubModal';
|
||||||
|
import Paginator from '../components/Paginator';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -168,82 +171,33 @@ function UnsubscribedPage() {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Paginator
|
||||||
<div style={paginatorWrapperStyle}>
|
page={page}
|
||||||
<button onClick={() => setPage(page - 1)} disabled={page <= 1} style={{ ...paginatorBtnStyle, opacity: page <= 1 ? 0.5 : 1 }}>←</button>
|
total={total}
|
||||||
{Array.from({ length: Math.ceil(total / PAGE_SIZE) || 1 }, (_, i) => (
|
pageSize={PAGE_SIZE}
|
||||||
<button
|
onPageChange={setPage}
|
||||||
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 && (
|
{editSub && (
|
||||||
<div style={modalOverlayStyle}>
|
<EditUnsubModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!editSub}
|
||||||
<button
|
onClose={() => setEditSub(null)}
|
||||||
type="button"
|
sub={editSub}
|
||||||
onClick={() => setEditSub(null)}
|
loading={editLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setEditSub}
|
||||||
aria-label="Закрыть"
|
onSave={handleEditSave}
|
||||||
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 && (
|
{createSub && (
|
||||||
<div style={modalOverlayStyle}>
|
<CreateUnsubModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!createSub}
|
||||||
<button
|
onClose={() => setCreateSub(null)}
|
||||||
type="button"
|
sub={createSub}
|
||||||
onClick={() => setCreateSub(null)}
|
loading={createLoading}
|
||||||
style={closeBtnStyle}
|
onChange={setCreateSub}
|
||||||
aria-label="Закрыть"
|
onSave={handleCreateSave}
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -260,9 +214,5 @@ const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border
|
|||||||
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 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 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 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;
|
export default UnsubscribedPage;
|
||||||
@ -1,5 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useUser } from '../context/UserContext';
|
import { useUser } from '../context/UserContext';
|
||||||
|
import EditUserModal from '../modals/EditUserModal';
|
||||||
|
import CreateUserModal from '../modals/CreateUserModal';
|
||||||
|
import Paginator from '../components/Paginator';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
@ -199,99 +202,35 @@ function UsersPage() {
|
|||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<Paginator
|
||||||
<div style={paginatorWrapperStyle}>
|
page={usersPage}
|
||||||
<button onClick={() => setUsersPage(usersPage - 1)} disabled={usersPage <= 1} style={{ ...paginatorBtnStyle, opacity: usersPage <= 1 ? 0.5 : 1 }}>←</button>
|
total={usersTotal}
|
||||||
{Array.from({ length: Math.ceil(usersTotal / PAGE_SIZE) || 1 }, (_, i) => (
|
pageSize={PAGE_SIZE}
|
||||||
<button
|
onPageChange={setUsersPage}
|
||||||
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 && (
|
{editUser && (
|
||||||
<div style={modalOverlayStyle}>
|
<EditUserModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!editUser}
|
||||||
<button
|
onClose={() => setEditUser(null)}
|
||||||
type="button"
|
user={editUser}
|
||||||
onClick={() => setEditUser(null)}
|
roles={roles}
|
||||||
style={closeBtnStyle}
|
loading={editLoading}
|
||||||
aria-label="Закрыть"
|
onChange={setEditUser}
|
||||||
disabled={editLoading}
|
onSave={handleEditSave}
|
||||||
>
|
/>
|
||||||
×
|
|
||||||
</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 && (
|
{createUser && (
|
||||||
<div style={modalOverlayStyle}>
|
<CreateUserModal
|
||||||
<div style={modalStyle}>
|
isOpen={!!createUser}
|
||||||
<button
|
onClose={() => setCreateUser(null)}
|
||||||
type="button"
|
user={createUser}
|
||||||
onClick={() => setCreateUser(null)}
|
roles={roles}
|
||||||
style={closeBtnStyle}
|
loading={createLoading}
|
||||||
aria-label="Закрыть"
|
onChange={setCreateUser}
|
||||||
disabled={createLoading}
|
onSave={handleCreateSave}
|
||||||
>
|
/>
|
||||||
×
|
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
@ -308,11 +247,6 @@ const inputStyle = { marginTop: 4, padding: '10px 12px', borderRadius: 8, border
|
|||||||
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 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 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' };
|
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;
|
export default UsersPage;
|
||||||
59
frontend/src/styles/CampaignModal.module.css
Normal file
59
frontend/src/styles/CampaignModal.module.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.title {
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #3730a3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid #c7d2fe;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.saveBtn {
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
59
frontend/src/styles/GroupModal.module.css
Normal file
59
frontend/src/styles/GroupModal.module.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.title {
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #3730a3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid #c7d2fe;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.saveBtn {
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
44
frontend/src/styles/Modal.module.css
Normal file
44
frontend/src/styles/Modal.module.css
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 32px 28px 24px 28px;
|
||||||
|
min-width: 340px;
|
||||||
|
box-shadow: 0 12px 48px 0 rgba(31,38,135,0.22);
|
||||||
|
position: relative;
|
||||||
|
animation: modalIn 0.18s cubic-bezier(.4,1.3,.6,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 16px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #6366f1;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes modalIn {
|
||||||
|
from { transform: translateY(40px) scale(0.98); opacity: 0; }
|
||||||
|
to { transform: none; opacity: 1; }
|
||||||
|
}
|
||||||
39
frontend/src/styles/Paginator.module.css
Normal file
39
frontend/src/styles/Paginator.module.css
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
.wrapper {
|
||||||
|
margin-top: 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
border: none;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.18s, color 0.18s;
|
||||||
|
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
|
||||||
|
}
|
||||||
|
.page {
|
||||||
|
border: none;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.18s, color 0.18s;
|
||||||
|
box-shadow: 0 1px 4px 0 rgba(99,102,241,0.06);
|
||||||
|
}
|
||||||
|
.pageActive {
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
|
color: #fff;
|
||||||
|
cursor: default;
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
}
|
||||||
59
frontend/src/styles/SmtpModal.module.css
Normal file
59
frontend/src/styles/SmtpModal.module.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.title {
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #3730a3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid #c7d2fe;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.saveBtn {
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
59
frontend/src/styles/SubscriberModal.module.css
Normal file
59
frontend/src/styles/SubscriberModal.module.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.title {
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #3730a3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid #c7d2fe;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.saveBtn {
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
59
frontend/src/styles/TemplateModal.module.css
Normal file
59
frontend/src/styles/TemplateModal.module.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.title {
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #3730a3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid #c7d2fe;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.saveBtn {
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
59
frontend/src/styles/UnsubModal.module.css
Normal file
59
frontend/src/styles/UnsubModal.module.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.title {
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #3730a3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid #c7d2fe;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.saveBtn {
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
59
frontend/src/styles/UserModal.module.css
Normal file
59
frontend/src/styles/UserModal.module.css
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
.title {
|
||||||
|
margin: 0 0 18px 0;
|
||||||
|
text-align: center;
|
||||||
|
color: #3730a3;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
min-width: 260px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.input {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1.5px solid #c7d2fe;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
background: #f8fafc;
|
||||||
|
transition: border 0.2s;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 10px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.saveBtn {
|
||||||
|
background: linear-gradient(90deg, #6366f1 0%, #06b6d4 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 22px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px 0 rgba(99,102,241,0.10);
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.cancelBtn {
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #6366f1;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user