dashboard
This commit is contained in:
parent
e793ef1de6
commit
8951edbc9b
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
45
frontend/src/components/MainLayout.js
Normal file
45
frontend/src/components/MainLayout.js
Normal 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;
|
||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
@ -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%;
|
||||||
@ -383,4 +438,261 @@
|
|||||||
.emptyStateText {
|
.emptyStateText {
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user