dashboard

This commit is contained in:
romantarkin 2025-08-02 16:39:09 +05:00
parent e793ef1de6
commit 8951edbc9b
5 changed files with 768 additions and 37 deletions

View File

@ -2,6 +2,14 @@ import React from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login'; import Login from './pages/Login';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import UsersPage from './pages/UsersPage';
import SmtpServersPage from './pages/SmtpServersPage';
import EmailTemplatesPage from './pages/EmailTemplatesPage';
import UnsubscribedPage from './pages/UnsubscribedPage';
import GroupsPage from './pages/GroupsPage';
import DeliveryHistoryPage from './pages/DeliveryHistoryPage';
import CampaignPage from './pages/CampaignPage';
import MainLayout from './components/MainLayout';
import './App.css'; import './App.css';
import { useUser } from './context/UserContext'; import { useUser } from './context/UserContext';
@ -18,8 +26,101 @@ function App() {
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route <Route
path="/dashboard" path="/dashboard"
element={user && token ? <Dashboard /> : <Navigate to="/login" replace />} /> element={
<Route path="*" element={<Navigate to="/login" replace />} /> user && token ? (
<MainLayout>
<Dashboard />
</MainLayout>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/users"
element={
user && token ? (
<MainLayout>
<UsersPage />
</MainLayout>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/smtp"
element={
user && token ? (
<MainLayout>
<SmtpServersPage />
</MainLayout>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/template"
element={
user && token ? (
<MainLayout>
<EmailTemplatesPage />
</MainLayout>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/unsubscribed"
element={
user && token ? (
<MainLayout>
<UnsubscribedPage />
</MainLayout>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/groups"
element={
user && token ? (
<MainLayout>
<GroupsPage />
</MainLayout>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/history"
element={
user && token ? (
<MainLayout>
<DeliveryHistoryPage />
</MainLayout>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route
path="/campaign"
element={
user && token ? (
<MainLayout>
<CampaignPage />
</MainLayout>
) : (
<Navigate to="/login" replace />
)
}
/>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>
</Router> </Router>
); );

View File

@ -0,0 +1,45 @@
import React, { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import SideMenu from './SideMenu';
import Header from './Header';
import { useUser } from '../context/UserContext';
import styles from '../styles/Common.module.css';
const MainLayout = ({ children }) => {
const [active, setActive] = useState('dashboard');
const { user, logout } = useUser();
const location = useLocation();
// Определяем активный пункт меню на основе текущего URL
useEffect(() => {
const path = location.pathname;
if (path === '/dashboard') setActive('dashboard');
else if (path === '/users') setActive('users');
else if (path === '/smtp') setActive('smtp');
else if (path === '/template') setActive('template');
else if (path === '/unsubscribed') setActive('unsubscribed');
else if (path === '/groups') setActive('groups');
else if (path === '/history') setActive('history');
else if (path === '/campaign') setActive('campaign');
else setActive('dashboard');
}, [location.pathname]);
const handleLogout = () => {
logout();
window.location.href = '/login';
};
return (
<div className={styles.dashboard}>
<SideMenu active={active} onSelect={setActive} />
<div className={styles.content}>
<Header user={user} onLogout={handleLogout} />
<div className={styles.pageContent}>
{children}
</div>
</div>
</div>
);
};
export default MainLayout;

View File

@ -1,12 +1,44 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import styles from './SideMenu.module.css'; import styles from './SideMenu.module.css';
const SideMenu = ({ active, onSelect }) => { const SideMenu = ({ active, onSelect }) => {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const navigate = useNavigate();
const handleMenuSelect = (menuItem) => { const handleMenuSelect = (menuItem) => {
onSelect(menuItem); onSelect(menuItem);
setIsMobileMenuOpen(false); setIsMobileMenuOpen(false);
// Навигация по URL
switch (menuItem) {
case 'dashboard':
navigate('/dashboard');
break;
case 'users':
navigate('/users');
break;
case 'smtp':
navigate('/smtp');
break;
case 'template':
navigate('/template');
break;
case 'unsubscribed':
navigate('/unsubscribed');
break;
case 'groups':
navigate('/groups');
break;
case 'history':
navigate('/history');
break;
case 'campaign':
navigate('/campaign');
break;
default:
navigate('/dashboard');
}
}; };
const toggleMobileMenu = () => { const toggleMobileMenu = () => {
@ -40,6 +72,7 @@ const SideMenu = ({ active, onSelect }) => {
<nav className={styles.nav}> <nav className={styles.nav}>
<div className={styles.section}>Email-рассылки</div> <div className={styles.section}>Email-рассылки</div>
<ul> <ul>
<li className={active === 'dashboard' ? styles.active : ''} onClick={() => handleMenuSelect('dashboard')}>Главная</li>
<li className={active === 'smtp' ? styles.active : ''} onClick={() => handleMenuSelect('smtp')}>SMTP-сервера</li> <li className={active === 'smtp' ? styles.active : ''} onClick={() => handleMenuSelect('smtp')}>SMTP-сервера</li>
<li className={active === 'template' ? styles.active : ''} onClick={() => handleMenuSelect('template')}>Шаблон письма</li> <li className={active === 'template' ? styles.active : ''} onClick={() => handleMenuSelect('template')}>Шаблон письма</li>
<li className={active === 'unsubscribed' ? styles.active : ''} onClick={() => handleMenuSelect('unsubscribed')}>Отписались</li> <li className={active === 'unsubscribed' ? styles.active : ''} onClick={() => handleMenuSelect('unsubscribed')}>Отписались</li>

View File

@ -1,49 +1,289 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import SideMenu from '../components/SideMenu';
import Header from '../components/Header';
import { useUser } from '../context/UserContext'; import { useUser } from '../context/UserContext';
import UsersPage from './UsersPage'; import styles from '../styles/Common.module.css';
import SmtpServersPage from './SmtpServersPage';
import EmailTemplatesPage from './EmailTemplatesPage';
import UnsubscribedPage from './UnsubscribedPage';
import GroupsPage from './GroupsPage';
import DeliveryHistoryPage from './DeliveryHistoryPage';
import CampaignPage from './CampaignPage';
import styles from './Dashboard.module.css';
const Dashboard = () => { function Dashboard() {
const [active, setActive] = useState('smtp'); const { token } = useUser();
const { user, logout } = useUser(); const [stats, setStats] = useState({
totalSubscribers: 0,
totalGroups: 0,
totalTemplates: 0,
totalCampaigns: 0,
totalSent: 0,
totalOpened: 0,
totalClicked: 0,
totalUnsubscribed: 0,
recentCampaigns: [],
recentDeliveries: []
});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const handleLogout = () => { useEffect(() => {
logout(); fetchDashboardStats();
window.location.href = '/login'; }, []);
const fetchDashboardStats = async () => {
setLoading(true);
setError('');
try {
// Получаем статистику подписчиков
const subscribersRes = await fetch('/api/mail/subscribers?limit=1', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const subscribersData = await subscribersRes.json();
const totalSubscribers = subscribersData.count || 0;
// Получаем статистику групп
const groupsRes = await fetch('/api/mail/mailing-groups?limit=1', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const groupsData = await groupsRes.json();
const totalGroups = groupsData.count || 0;
// Получаем статистику шаблонов
const templatesRes = await fetch('/api/mail/email-templates?limit=1', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const templatesData = await templatesRes.json();
const totalTemplates = templatesData.count || 0;
// Получаем статистику кампаний
const campaignsRes = await fetch('/api/mail/campaigns?limit=1', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const campaignsData = await campaignsRes.json();
const totalCampaigns = campaignsData.count || 0;
// Получаем статистику доставки
const deliveryRes = await fetch('/api/mail/delivery-logs?limit=1', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const deliveryData = await deliveryRes.json();
const totalSent = deliveryData.count || 0;
// Получаем статистику открытий и кликов
const openedRes = await fetch('/api/mail/delivery-logs?limit=1000', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const openedData = await openedRes.json();
const logs = openedData.rows || [];
const totalOpened = logs.filter(log => log.opened_at).length;
const totalClicked = logs.filter(log => log.clicked_at).length;
// Получаем статистику отписок
const unsubRes = await fetch('/api/mail/subscribers?limit=1&status=unsubscribed', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const unsubData = await unsubRes.json();
const totalUnsubscribed = unsubData.count || 0;
// Получаем последние кампании
const recentCampaignsRes = await fetch('/api/mail/campaigns?limit=5', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const recentCampaignsData = await recentCampaignsRes.json();
const recentCampaigns = recentCampaignsData.rows || [];
// Получаем последние доставки
const recentDeliveriesRes = await fetch('/api/mail/delivery-logs?limit=10', {
headers: token ? { Authorization: `Bearer ${token}` } : {}
});
const recentDeliveriesData = await recentDeliveriesRes.json();
const recentDeliveries = recentDeliveriesData.rows || [];
setStats({
totalSubscribers,
totalGroups,
totalTemplates,
totalCampaigns,
totalSent,
totalOpened,
totalClicked,
totalUnsubscribed,
recentCampaigns,
recentDeliveries
});
} catch (e) {
setError('Ошибка загрузки статистики');
} finally {
setLoading(false);
}
}; };
function renderPage() { const getOpenRate = () => {
switch (active) { if (stats.totalSent === 0) return 0;
case 'users': return <UsersPage />; return ((stats.totalOpened / stats.totalSent) * 100).toFixed(1);
case 'smtp': return <SmtpServersPage />; };
case 'template': return <EmailTemplatesPage />;
case 'unsubscribed': return <UnsubscribedPage />; const getClickRate = () => {
case 'groups': return <GroupsPage />; if (stats.totalSent === 0) return 0;
case 'history': return <DeliveryHistoryPage />; return ((stats.totalClicked / stats.totalSent) * 100).toFixed(1);
case 'campaign': return <CampaignPage />; };
default: return null;
} const getUnsubscribeRate = () => {
if (stats.totalSubscribers === 0) return 0;
return ((stats.totalUnsubscribed / stats.totalSubscribers) * 100).toFixed(1);
};
if (loading) {
return (
<div className={styles.container}>
<div className={styles.loading}>Загрузка аналитики...</div>
</div>
);
}
if (error) {
return (
<div className={styles.container}>
<div className={styles.error}>{error}</div>
</div>
);
} }
return ( return (
<div className={styles.dashboard}> <div className={styles.container}>
<SideMenu active={active} onSelect={setActive} /> <div className={styles.pageHeader}>
<div className={styles.content}> <h1 className={styles.pageTitle}>Аналитика Email-рассылок</h1>
<Header user={user} onLogout={handleLogout} /> </div>
<div className={styles.pageContent}>
{renderPage()} {/* Основные метрики */}
<div className={styles.statsGrid}>
<div className={styles.statCard}>
<div className={styles.statIcon}>👥</div>
<div className={styles.statContent}>
<div className={styles.statNumber}>{stats.totalSubscribers}</div>
<div className={styles.statLabel}>Подписчиков</div>
</div>
</div> </div>
<div className={styles.statCard}>
<div className={styles.statIcon}>📧</div>
<div className={styles.statContent}>
<div className={styles.statNumber}>{stats.totalSent}</div>
<div className={styles.statLabel}>Отправлено писем</div>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statIcon}>📊</div>
<div className={styles.statContent}>
<div className={styles.statNumber}>{getOpenRate()}%</div>
<div className={styles.statLabel}>Процент открытий</div>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statIcon}>🔗</div>
<div className={styles.statContent}>
<div className={styles.statNumber}>{getClickRate()}%</div>
<div className={styles.statLabel}>Процент кликов</div>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statIcon}>📝</div>
<div className={styles.statContent}>
<div className={styles.statNumber}>{stats.totalTemplates}</div>
<div className={styles.statLabel}>Шаблонов</div>
</div>
</div>
<div className={styles.statCard}>
<div className={styles.statIcon}>🎯</div>
<div className={styles.statContent}>
<div className={styles.statNumber}>{stats.totalCampaigns}</div>
<div className={styles.statLabel}>Кампаний</div>
</div>
</div>
</div>
{/* Дополнительная статистика */}
<div className={styles.additionalStats}>
<div className={styles.statRow}>
<div className={styles.statItem}>
<span className={styles.statLabel}>Групп подписчиков:</span>
<span className={styles.statValue}>{stats.totalGroups}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>Открыто писем:</span>
<span className={styles.statValue}>{stats.totalOpened}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>Кликов по ссылкам:</span>
<span className={styles.statValue}>{stats.totalClicked}</span>
</div>
<div className={styles.statItem}>
<span className={styles.statLabel}>Отписок:</span>
<span className={styles.statValue}>{stats.totalUnsubscribed} ({getUnsubscribeRate()}%)</span>
</div>
</div>
</div>
{/* Последние кампании */}
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Последние кампании</h2>
{stats.recentCampaigns.length > 0 ? (
<div className={styles.campaignsList}>
{stats.recentCampaigns.map(campaign => (
<div key={campaign.id} className={styles.campaignItem}>
<div className={styles.campaignInfo}>
<div className={styles.campaignName}>Кампания #{campaign.id}</div>
<div className={styles.campaignStatus}>
<span className={`${styles.statusBadge} ${styles[`status${campaign.status}`]}`}>
{campaign.status}
</span>
</div>
</div>
<div className={styles.campaignDate}>
{campaign.scheduled_at ? new Date(campaign.scheduled_at).toLocaleDateString() : 'Не запланировано'}
</div>
</div>
))}
</div>
) : (
<div className={styles.emptyState}>
<div className={styles.emptyStateIcon}>📧</div>
<div className={styles.emptyStateTitle}>Нет кампаний</div>
<div className={styles.emptyStateText}>Создайте первую кампанию для начала работы</div>
</div>
)}
</div>
{/* Последние доставки */}
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Последние доставки</h2>
{stats.recentDeliveries.length > 0 ? (
<div className={styles.deliveriesList}>
{stats.recentDeliveries.slice(0, 5).map(delivery => (
<div key={delivery.id} className={styles.deliveryItem}>
<div className={styles.deliveryInfo}>
<div className={styles.deliveryEmail}>
{delivery.Subscriber?.email || delivery.subscriber_id}
</div>
<div className={styles.deliveryStatus}>
<span className={`${styles.statusBadge} ${styles[`status${delivery.status}`]}`}>
{delivery.status}
</span>
</div>
</div>
<div className={styles.deliveryDate}>
{delivery.sent_at ? new Date(delivery.sent_at).toLocaleString() : 'Не отправлено'}
</div>
</div>
))}
</div>
) : (
<div className={styles.emptyState}>
<div className={styles.emptyStateIcon}>📊</div>
<div className={styles.emptyStateTitle}>Нет доставок</div>
<div className={styles.emptyStateText}>История доставок появится после отправки писем</div>
</div>
)}
</div> </div>
</div> </div>
); );
}; }
export default Dashboard; export default Dashboard;

View File

@ -1,3 +1,58 @@
/* Стили для макета приложения */
.dashboard {
display: flex;
min-height: 100vh;
background: #f8fafc;
}
.content {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0; /* Предотвращает переполнение на мобильных */
}
.pageContent {
flex: 1;
padding: 32px;
overflow-x: auto;
}
/* Адаптивные стили для планшетов */
@media (max-width: 1024px) {
.pageContent {
padding: 24px;
}
}
/* Адаптивные стили для мобильных устройств */
@media (max-width: 768px) {
.dashboard {
flex-direction: column;
}
.content {
margin-left: 0;
}
.pageContent {
padding: 16px;
}
}
@media (max-width: 480px) {
.pageContent {
padding: 12px;
}
}
/* Улучшенная поддержка touch устройств */
@media (hover: none) and (pointer: coarse) {
.pageContent {
-webkit-overflow-scrolling: touch;
}
}
/* Общие стили для таблиц */ /* Общие стили для таблиц */
.table { .table {
width: 100%; width: 100%;
@ -384,3 +439,260 @@
font-size: 14px; font-size: 14px;
color: #9ca3af; color: #9ca3af;
} }
/* Стили для главной страницы с аналитикой */
.statsGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 32px;
}
.statCard {
background: #fff;
border-radius: 8px;
padding: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
gap: 8px;
transition: transform 0.2s, box-shadow 0.2s;
}
.statCard:hover {
transform: translateY(-2px);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.statIcon {
font-size: 20px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #6366f1, #06b6d4);
border-radius: 8px;
color: white;
flex-shrink: 0;
}
.statContent {
flex: 1;
min-width: 0;
}
.statNumber {
font-size: 18px;
font-weight: 700;
color: #111827;
margin-bottom: 2px;
line-height: 1.1;
}
.statLabel {
font-size: 11px;
color: #6b7280;
font-weight: 500;
line-height: 1.1;
}
.additionalStats {
background: #fff;
border-radius: 12px;
padding: 24px;
margin-bottom: 32px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.statRow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.statItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.statItem:last-child {
border-bottom: none;
}
.statItem .statLabel {
font-size: 14px;
color: #6b7280;
font-weight: 500;
}
.statItem .statValue {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.section {
background: #fff;
border-radius: 12px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.sectionTitle {
font-size: 20px;
font-weight: 600;
color: #111827;
margin: 0 0 20px 0;
}
.campaignsList,
.deliveriesList {
display: flex;
flex-direction: column;
gap: 12px;
}
.campaignItem,
.deliveryItem {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: #f9fafb;
border-radius: 8px;
border: 1px solid #e5e7eb;
}
.campaignInfo,
.deliveryInfo {
display: flex;
flex-direction: column;
gap: 4px;
}
.campaignName,
.deliveryEmail {
font-size: 16px;
font-weight: 500;
color: #111827;
}
.campaignDate,
.deliveryDate {
font-size: 14px;
color: #6b7280;
}
.campaignStatus,
.deliveryStatus {
display: flex;
gap: 8px;
}
.statusBadge {
padding: 4px 8px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.statusdraft {
background: #fef3c7;
color: #92400e;
}
.statusactive {
background: #d1fae5;
color: #065f46;
}
.statussent {
background: #dbeafe;
color: #1e40af;
}
.statusdelivered {
background: #d1fae5;
color: #065f46;
}
.statusfailed {
background: #fee2e2;
color: #991b1b;
}
.statusopened {
background: #dbeafe;
color: #1e40af;
}
.statusclicked {
background: #e0e7ff;
color: #3730a3;
}
/* Адаптивные стили для главной страницы */
@media (max-width: 768px) {
.statsGrid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
}
.statCard {
padding: 12px;
}
.statIcon {
width: 40px;
height: 40px;
font-size: 18px;
}
.statNumber {
font-size: 18px;
}
.statRow {
grid-template-columns: 1fr;
gap: 12px;
}
.campaignItem,
.deliveryItem {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.campaignDate,
.deliveryDate {
align-self: flex-end;
}
}
@media (max-width: 480px) {
.statsGrid {
grid-template-columns: 1fr;
}
.statCard {
padding: 10px;
}
.statIcon {
width: 32px;
height: 32px;
font-size: 16px;
}
.statNumber {
font-size: 16px;
}
}