Compare commits

...

22 Commits

Author SHA1 Message Date
aleksey de379e28bf fix(charts): единый стиль AreaChart с градиентом
CI / test (push) Successful in 1m8s
CI / push-image (push) Successful in 6m9s
CI / ci-done (push) Successful in 0s
Deploy stage (admin) / deploy-stage-admin (push) Successful in 4m46s
Общий AreaTrendChart для мониторинга и daily-графиков, StatisticSparklineCard на дашборде.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:55:24 +03:00
aleksey d056b63e1b fix(i18n): русские метки статусов в таблицах
formatStatusLabel для тегов статусов на list-страницах.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:54:55 +03:00
aleksey 2056333529 fix(tables): сохранение размера страницы и showSizeChanger
Размер таблицы сохраняется в localStorage, опции 10/20/50/100 на всех list-страницах.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:54:53 +03:00
aleksey a1bd11afee fix(dashboard): ролевые виды и ссылка на аудит вместо дубля
Moderator/support видят свою статистику; admin_activity заменён ссылкой на /audit.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:54:51 +03:00
aleksey bc8ec9aeea fix(ws): маршруты сущностей и локализованные уведомления
Клик по WS-уведомлению ведёт на детальную страницу, тексты через i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:54:25 +03:00
aleksey 874d9099de fix(monitoring): дедупликация точек в истории метрик
addToAllHistory не добавляет повторяющиеся timestamp+node.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:54:24 +03:00
aleksey bc20deac23 fix(profile): редактирование профиля через PUT /v1/admin/me
Self-edit доступен всем ролям без PUT /admins/:id.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:54:23 +03:00
aleksey fd825ddfd7 fix(auth): i18n и react-hook-form на странице логина
Добавлены i18next, схема zod и react-hook-form для формы входа.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:54:00 +03:00
aleksey 755d22cf48 fix(users): отображение users_by_role в статистике
Refs EventHub/EventHubFrontAdmin#29

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:35:52 +03:00
aleksey 72f973bbd1 fix(subscriptions): subscriptions_by_plan и ending_paid_subscriptions
Refs EventHub/EventHubFrontAdmin#28

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:35:52 +03:00
aleksey 190cca7e6b fix(stats): top-списки и группировка по типу на list-страницах
Refs EventHub/EventHubFrontAdmin#27

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:35:51 +03:00
aleksey 61da5b6d3a fix(dashboard): daily-графики и счётчики из GET /v1/admin/stats
Refs EventHub/EventHubFrontAdmin#26

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:35:51 +03:00
aleksey da3fcbb6b0 fix(monitoring): отображение всех метрик узла на графиках
Refs EventHub/EventHubFrontAdmin#24

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:27:43 +03:00
aleksey 8adf982587 feat(monitoring): загрузка истории метрик через REST
Refs EventHub/EventHubFrontAdmin#25

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:27:43 +03:00
aleksey b7fb56b8cc fix(403): название раздела на странице Forbidden
Refs EventHub/EventHubFrontAdmin#22

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:27:42 +03:00
aleksey 96fb44c770 fix(login): не перехватывать 401 на /v1/admin/login
Исключает refresh/reload формы при неверном пароле.

Refs EventHub/EventHubFrontAdmin#12

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:19:26 +03:00
aleksey 4b8069ad74 fix(login): убрать console.log с credentials
Refs EventHub/EventHubFrontAdmin#10

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:19:00 +03:00
aleksey b30607df3f fix(ws): не показывать replay-уведомления при подключении
Refs EventHub/EventHubFrontAdmin#9

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:18:22 +03:00
aleksey 9ff894988f fix(ws): фильтрация уведомлений и подписок по роли
Refs EventHub/EventHubFrontAdmin#8

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:14:51 +03:00
aleksey 9deaa34a6e fix(menu): подсветка родительского пункта на detail-страницах
Refs EventHub/EventHubFrontAdmin#7

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:06:59 +03:00
aleksey 7a2467370a fix(reports): useEvent только для target_type=event
Refs EventHub/EventHubFrontAdmin#6

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 17:03:14 +03:00
aleksey 28404eb7be fix(profile): Avatar src — URL строка вместо JSX
Refs EventHub/EventHubFrontAdmin#5

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-13 16:59:25 +03:00
39 changed files with 3805 additions and 439 deletions
+2696 -31
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -16,4 +16,8 @@ export const authApi = {
const { data } = await apiClient.get('/v1/admin/me'); const { data } = await apiClient.get('/v1/admin/me');
return data; return data;
}, },
updateMe: async (payload: Partial<Admin>): Promise<Admin> => {
const { data } = await apiClient.put('/v1/admin/me', payload);
return data;
},
}; };
+9 -1
View File
@@ -10,6 +10,9 @@ const apiClient = axios.create({
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean }; type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
const isLoginRequest = (config?: InternalAxiosRequestConfig) =>
Boolean(config?.url?.includes('/v1/admin/login'));
let isRefreshing = false; let isRefreshing = false;
let pendingRequests: Array<{ let pendingRequests: Array<{
resolve: (token: string) => void; resolve: (token: string) => void;
@@ -45,7 +48,12 @@ apiClient.interceptors.response.use(
(response) => response, (response) => response,
async (error: AxiosError) => { async (error: AxiosError) => {
const originalRequest = error.config as RetryConfig | undefined; const originalRequest = error.config as RetryConfig | undefined;
if (!originalRequest || error.response?.status !== 401 || originalRequest._retry) { if (
!originalRequest ||
error.response?.status !== 401 ||
originalRequest._retry ||
isLoginRequest(originalRequest)
) {
return Promise.reject(error); return Promise.reject(error);
} }
+11
View File
@@ -0,0 +1,11 @@
import apiClient from './client';
import { NodeMetric } from '../store/metricsStore';
export const monitoringApi = {
getNodeMetrics: async (from: string, to: string, node = 'all'): Promise<NodeMetric[]> => {
const { data } = await apiClient.get<NodeMetric[]>('/v1/admin/nodes/metrics', {
params: { from, to, node },
});
return data;
},
};
+68
View File
@@ -0,0 +1,68 @@
import React from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
export interface AreaTrendSeries {
key: string;
color: string;
name: string;
}
interface Props {
data: Record<string, unknown>[];
xKey: string;
series: AreaTrendSeries[];
height?: number;
xMinTickGap?: number;
yAllowDecimals?: boolean;
}
const AreaTrendChart: React.FC<Props> = ({
data,
xKey,
series,
height = 280,
xMinTickGap = 30,
yAllowDecimals = true,
}) => (
<ResponsiveContainer width="100%" height={height}>
<AreaChart data={data}>
<defs>
{series.map((s) => (
<linearGradient key={s.key} id={`grad-${s.key}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={s.color} stopOpacity={0.4} />
<stop offset="95%" stopColor={s.color} stopOpacity={0.05} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey={xKey} minTickGap={xMinTickGap} />
<YAxis allowDecimals={yAllowDecimals} />
<Tooltip />
<Legend />
{series.map((s) => (
<Area
key={s.key}
type="monotone"
dataKey={s.key}
stroke={s.color}
fill={`url(#grad-${s.key})`}
strokeWidth={2}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
name={s.name}
/>
))}
</AreaChart>
</ResponsiveContainer>
);
export default AreaTrendChart;
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import { Card, Col } from 'antd';
import { DayCount } from '../types/api';
import AreaTrendChart from './AreaTrendChart';
interface Props {
title: string;
data?: DayCount[];
color: string;
lg?: number;
}
const DailyLineChartCard: React.FC<Props> = ({ title, data, color, lg = 12 }) => {
const chartData = (data ?? []).map((item) => ({
date: item.date,
count: item.count,
}));
if (chartData.length === 0) {
return null;
}
return (
<Col xs={24} lg={lg}>
<Card title={title}>
<AreaTrendChart
data={chartData}
xKey="date"
series={[{ key: 'count', color, name: title }]}
height={300}
yAllowDecimals={false}
/>
</Card>
</Col>
);
};
export default DailyLineChartCard;
+30
View File
@@ -0,0 +1,30 @@
import React, { useEffect } from 'react';
import { ConfigProvider } from 'antd';
import ruRU from 'antd/locale/ru_RU';
import enUS from 'antd/locale/en_US';
import dayjs from 'dayjs';
import 'dayjs/locale/ru';
import 'dayjs/locale/en';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../store/authStore';
const antdLocales = { ru: ruRU, en: enUS } as const;
const LocaleProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { i18n } = useTranslation();
const language = useAuthStore((s) => s.user?.language);
const locale = language === 'en' ? 'en' : 'ru';
useEffect(() => {
void i18n.changeLanguage(locale);
dayjs.locale(locale);
}, [i18n, locale]);
return (
<ConfigProvider locale={antdLocales[locale]}>
{children}
</ConfigProvider>
);
};
export default LocaleProvider;
+65
View File
@@ -0,0 +1,65 @@
import React from 'react';
import { Card, Statistic } from 'antd';
import { AreaChart, Area, XAxis, Tooltip } from 'recharts';
interface SparklinePoint {
date: string;
value: number;
}
interface Props {
title: string;
value: number;
prefix?: React.ReactNode;
data?: SparklinePoint[];
color?: string;
gradientId: string;
}
const StatisticSparklineCard: React.FC<Props> = ({
title,
value,
prefix,
data,
color = '#1890ff',
gradientId,
}) => (
<Card variant="outlined">
<div style={{ display: 'flex', alignItems: 'center' }}>
<Statistic
title={title}
value={value}
prefix={prefix}
style={{ flex: '0 0 auto', marginRight: 16 }}
/>
{data && data.length > 0 && (
<AreaChart
width={150}
height={50}
data={data}
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={color} stopOpacity={0.4} />
<stop offset="95%" stopColor={color} stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
<Area
type="monotone"
dataKey="value"
stroke={color}
fill={`url(#${gradientId})`}
strokeWidth={2}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
/>
</AreaChart>
)}
</div>
</Card>
);
export default StatisticSparklineCard;
+28
View File
@@ -52,3 +52,31 @@ export function filterMenuByRole<T extends { roles?: AdminRole[] }>(
): T[] { ): T[] {
return items.filter((item) => !item.roles || (role && item.roles.includes(role as AdminRole))); return items.filter((item) => !item.roles || (role && item.roles.includes(role as AdminRole)));
} }
/** Ключ пункта меню для detail-маршрутов (/users/:id → /users). */
export function getMenuSelectedKey(pathname: string): string {
if (MENU_ITEMS.some((item) => item.key === pathname)) {
return pathname;
}
const segment = pathname.split('/').filter(Boolean)[0];
if (segment) {
const parent = `/${segment}`;
if (MENU_ITEMS.some((item) => item.key === parent)) {
return parent;
}
}
return pathname;
}
const WS_NOTIFICATION_ROUTES: Record<'report_created' | 'ticket_created', string> = {
report_created: '/reports',
ticket_created: '/tickets',
};
/** Доступ к WS-уведомлению по роли (согласовано с ROUTE_ACCESS). */
export function canReceiveWsNotification(
type: 'report_created' | 'ticket_created',
role: string | undefined
): boolean {
return canAccessRoute(WS_NOTIFICATION_ROUTES[type], role);
}
+39 -11
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore'; import { useAuthStore } from '../store/authStore';
import { useMetricsStore } from '../store/metricsStore'; import { useMetricsStore } from '../store/metricsStore';
import { canAccessRoute, canReceiveWsNotification } from '../config/adminAccess';
type WsMessage = { type WsMessage = {
type: 'report_created' | 'ticket_created' | 'node_metric'; type: 'report_created' | 'ticket_created' | 'node_metric';
@@ -11,10 +12,17 @@ type WsMessage = {
timestamp?: number; timestamp?: number;
}; };
const wsLog = (...args: unknown[]) => {
if (import.meta.env.DEV) {
console.log(...args);
}
};
export const useAdminWebSocket = () => { export const useAdminWebSocket = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const accessToken = useAuthStore((s) => s.accessToken); const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated); const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const role = useAuthStore((s) => s.user?.role);
const setCurrentMetric = useMetricsStore((s) => s.setCurrent); const setCurrentMetric = useMetricsStore((s) => s.setCurrent);
const addToAllHistory = useMetricsStore((s) => s.addToAllHistory); const addToAllHistory = useMetricsStore((s) => s.addToAllHistory);
@@ -59,6 +67,7 @@ export const useAdminWebSocket = () => {
shouldReconnectRef.current = true; shouldReconnectRef.current = true;
let isMounted = true; let isMounted = true;
const sessionStartSecondsRef = { current: 0 };
const connect = () => { const connect = () => {
if (!isMounted || !shouldReconnectRef.current) return; if (!isMounted || !shouldReconnectRef.current) return;
@@ -74,9 +83,14 @@ export const useAdminWebSocket = () => {
ws.onopen = () => { ws.onopen = () => {
if (!isMounted || !shouldReconnectRef.current) return; if (!isMounted || !shouldReconnectRef.current) return;
console.log('[WS] Connected'); sessionStartSecondsRef.current = Math.floor(Date.now() / 1000);
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' })); wsLog('[WS] Connected');
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' })); if (canAccessRoute('/reports', role)) {
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
}
if (canAccessRoute('/tickets', role)) {
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
}
pingIntervalRef.current = setInterval(() => { pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
@@ -89,7 +103,7 @@ export const useAdminWebSocket = () => {
if (!isMounted) return; if (!isMounted) return;
try { try {
const msg: WsMessage = JSON.parse(event.data); const msg: WsMessage = JSON.parse(event.data);
console.log('[WS] Message:', msg); wsLog('[WS] Message:', msg);
if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) { if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) {
return; return;
@@ -99,9 +113,19 @@ export const useAdminWebSocket = () => {
} }
if (msg.type === 'report_created' || msg.type === 'ticket_created') { if (msg.type === 'report_created' || msg.type === 'ticket_created') {
window.dispatchEvent( if (!canReceiveWsNotification(msg.type, role)) {
new CustomEvent('admin-ws-message', { detail: msg }) return;
); }
const isLiveEvent =
!msg.timestamp || msg.timestamp >= sessionStartSecondsRef.current;
if (isLiveEvent) {
window.dispatchEvent(
new CustomEvent('admin-ws-message', { detail: msg })
);
}
if (msg.type === 'report_created') { if (msg.type === 'report_created') {
queryClient.invalidateQueries({ queryKey: ['reports'] }); queryClient.invalidateQueries({ queryKey: ['reports'] });
} else if (msg.type === 'ticket_created') { } else if (msg.type === 'ticket_created') {
@@ -113,13 +137,15 @@ export const useAdminWebSocket = () => {
addToAllHistory(msg.data); addToAllHistory(msg.data);
} }
} catch (e) { } catch (e) {
console.warn('[WS] Failed to parse message:', e); if (import.meta.env.DEV) {
console.warn('[WS] Failed to parse message:', e);
}
} }
}; };
ws.onclose = (event) => { ws.onclose = (event) => {
if (!isMounted || !shouldReconnectRef.current) return; if (!isMounted || !shouldReconnectRef.current) return;
console.log('[WS] Disconnected:', event.reason); wsLog('[WS] Disconnected:', event.reason);
clearTimers(); clearTimers();
wsRef.current = null; wsRef.current = null;
reconnectTimeoutRef.current = setTimeout(connect, 5000); reconnectTimeoutRef.current = setTimeout(connect, 5000);
@@ -127,7 +153,9 @@ export const useAdminWebSocket = () => {
ws.onerror = (error) => { ws.onerror = (error) => {
if (!isMounted) return; if (!isMounted) return;
console.error('[WS] Error:', error); if (import.meta.env.DEV) {
console.error('[WS] Error:', error);
}
ws.close(); ws.close();
}; };
}; };
@@ -139,5 +167,5 @@ export const useAdminWebSocket = () => {
shouldReconnectRef.current = false; shouldReconnectRef.current = false;
closeSocket(); closeSocket();
}; };
}, [isAuthenticated, accessToken, queryClient, setCurrentMetric, addToAllHistory]); }, [isAuthenticated, accessToken, role, queryClient, setCurrentMetric, addToAllHistory]);
}; };
+18
View File
@@ -0,0 +1,18 @@
import { useMutation } from '@tanstack/react-query';
import { message } from 'antd';
import { authApi } from '../api/authApi';
import { useAuthStore } from '../store/authStore';
import { Admin } from '../types/api';
export const useUpdateProfile = () => {
const setUser = useAuthStore((s) => s.setUser);
return useMutation({
mutationFn: (data: Partial<Admin>) => authApi.updateMe(data),
onSuccess: (user) => {
setUser(user);
message.success('Профиль обновлён');
},
onError: () => message.error('Ошибка обновления профиля'),
});
};
+16
View File
@@ -0,0 +1,16 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import ru from '../locales/ru.json';
import en from '../locales/en.json';
void i18n.use(initReactI18next).init({
resources: {
ru: { translation: ru },
en: { translation: en },
},
lng: 'ru',
fallbackLng: 'ru',
interpolation: { escapeValue: false },
});
export default i18n;
+31 -33
View File
@@ -23,7 +23,9 @@ import {
CloudServerOutlined, CloudServerOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { MENU_ITEMS, filterMenuByRole } from '../config/adminAccess'; import { MENU_ITEMS, filterMenuByRole, getMenuSelectedKey, canReceiveWsNotification } from '../config/adminAccess';
import { getEntityDetailPath } from '../utils/entityRoutes';
import { useTranslation } from 'react-i18next';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
const { Text } = Typography; const { Text } = Typography;
@@ -35,6 +37,7 @@ const AdminLayout: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
const { t } = useTranslation();
const { token: { colorBgContainer } } = theme.useToken(); const { token: { colorBgContainer } } = theme.useToken();
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
@@ -45,50 +48,45 @@ const AdminLayout: React.FC = () => {
const handler = (event: CustomEvent) => { const handler = (event: CustomEvent) => {
const msg = event.detail; const msg = event.detail;
if (msg.type === 'report_created') { if (msg.type === 'report_created') {
if (!canReceiveWsNotification('report_created', user?.role)) {
return;
}
const { report_id, target_type, target_id, reason } = msg.data || {}; const { report_id, target_type, target_id, reason } = msg.data || {};
const targetPath = target_id ? getEntityDetailPath(target_type, target_id) : undefined;
notification.info({ notification.info({
title: 'Новая жалоба', key: report_id ? `report-${report_id}` : `report-${Date.now()}`,
description: ( message: t('ws.newReport'),
<span> description: [
Жалоба{' '} report_id ? `Жалоба #${report_id}` : 'Жалоба',
{report_id ? ( target_type ? `на ${target_type}` : '',
<a href={`/reports/${report_id}`} onClick={(e) => { e.preventDefault(); navigate(`/reports/${report_id}`); }} style={{ fontWeight: 600 }}> target_id ?? '',
#{report_id} reason ? `(${reason})` : '',
</a> ].filter(Boolean).join(' '),
) : ('#?')}{' '}
на {target_type || 'неизвестный тип'}{' '}
{target_id ? (
<a href={`/${target_type}s/${target_id}`} onClick={(e) => { e.preventDefault(); navigate(`/${target_type}s/${target_id}`); }} style={{ fontWeight: 600 }}>
{target_id}
</a>
) : ('?')}{' '}
{reason ? `(${reason})` : ''}
</span>
),
placement: 'topRight', placement: 'topRight',
onClick: () => {
if (report_id) navigate(`/reports/${report_id}`);
else if (targetPath) navigate(targetPath);
},
}); });
} else if (msg.type === 'ticket_created') { } else if (msg.type === 'ticket_created') {
if (!canReceiveWsNotification('ticket_created', user?.role)) {
return;
}
const { ticket_id } = msg.data || {}; const { ticket_id } = msg.data || {};
notification.info({ notification.info({
title: 'Новый тикет', key: ticket_id ? `ticket-${ticket_id}` : `ticket-${Date.now()}`,
description: ( message: t('ws.newTicket'),
<span> description: ticket_id ? `Тикет #${ticket_id} создан` : 'Создан новый тикет',
Тикет{' '}
{ticket_id ? (
<a href={`/tickets/${ticket_id}`} onClick={(e) => { e.preventDefault(); navigate(`/tickets/${ticket_id}`); }} style={{ fontWeight: 600 }}>
#{ticket_id}
</a>
) : ('без ID')}{' '}
создан
</span>
),
placement: 'topRight', placement: 'topRight',
onClick: () => {
if (ticket_id) navigate(`/tickets/${ticket_id}`);
},
}); });
} }
}; };
window.addEventListener('admin-ws-message', handler as EventListener); window.addEventListener('admin-ws-message', handler as EventListener);
return () => window.removeEventListener('admin-ws-message', handler as EventListener); return () => window.removeEventListener('admin-ws-message', handler as EventListener);
}, [navigate]); }, [navigate, user?.role, t]);
const onlineNodes = React.useMemo(() => { const onlineNodes = React.useMemo(() => {
const cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute'); const cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute');
@@ -174,7 +172,7 @@ const AdminLayout: React.FC = () => {
<Menu <Menu
theme="dark" theme="dark"
mode="inline" mode="inline"
selectedKeys={[location.pathname]} selectedKeys={[getMenuSelectedKey(location.pathname)]}
items={filteredMenu} items={filteredMenu}
onClick={({ key }) => navigate(key)} onClick={({ key }) => navigate(key)}
style={{ style={{
+34
View File
@@ -0,0 +1,34 @@
{
"menu": {
"dashboard": "Dashboard",
"users": "Users",
"calendars": "Calendars",
"events": "Events",
"reports": "Reports",
"reviews": "Reviews",
"bannedWords": "Banned words",
"tickets": "Tickets",
"subscriptions": "Subscriptions",
"admins": "Admins",
"audit": "Audit",
"monitoring": "Monitoring"
},
"profile": {
"title": "My profile",
"edit": "Edit",
"saved": "Profile updated"
},
"login": {
"title": "Sign in",
"email": "Email",
"password": "Password",
"submit": "Sign in",
"invalid_credentials": "Invalid email or password",
"insufficient_permissions": "Insufficient permissions",
"error": "Sign-in error"
},
"ws": {
"newReport": "New report",
"newTicket": "New ticket"
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"menu": {
"dashboard": "Дашборд",
"users": "Пользователи",
"calendars": "Календари",
"events": "События",
"reports": "Жалобы",
"reviews": "Отзывы",
"bannedWords": "Бан-слова",
"tickets": "Тикеты",
"subscriptions": "Подписки",
"admins": "Администраторы",
"audit": "Аудит",
"monitoring": "Мониторинг"
},
"profile": {
"title": "Мой профиль",
"edit": "Редактировать",
"saved": "Профиль обновлён"
},
"login": {
"title": "Вход",
"email": "Email",
"password": "Пароль",
"submit": "Войти",
"invalid_credentials": "Неверный email или пароль",
"insufficient_permissions": "Недостаточно прав для входа",
"error": "Ошибка входа"
},
"ws": {
"newReport": "Новая жалоба",
"newTicket": "Новый тикет"
}
}
+4 -8
View File
@@ -1,17 +1,13 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import dayjs from 'dayjs';
import 'dayjs/locale/ru';
import locale from 'antd/locale/ru_RU';
import { ConfigProvider } from 'antd';
import App from './App'; import App from './App';
import './i18n';
dayjs.locale('ru'); import LocaleProvider from './components/LocaleProvider';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<ConfigProvider locale={locale}> <LocaleProvider>
<App /> <App />
</ConfigProvider> </LocaleProvider>
</React.StrictMode> </React.StrictMode>
); );
+7 -11
View File
@@ -5,10 +5,13 @@ import { useNavigate } from 'react-router-dom';
import { useAdmins, useCreateAdmin, useUpdateAdmin, useDeleteAdmin, useAdmin } from '../../hooks/useAdmins'; import { useAdmins, useCreateAdmin, useUpdateAdmin, useDeleteAdmin, useAdmin } from '../../hooks/useAdmins';
import { Admin, AdminListParams } from '../../types/api'; import { Admin, AdminListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
const TABLE_KEY = 'admins';
const AdminListPage: React.FC = () => { const AdminListPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [params, setParams] = useState<AdminListParams>({ limit: 20, offset: 0, sort: 'email', order: 'asc' }); const [params, setParams] = useState<AdminListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'email', order: 'asc' });
const { data, isLoading } = useAdmins(params); const { data, isLoading } = useAdmins(params);
const createAdmin = useCreateAdmin(); const createAdmin = useCreateAdmin();
const updateAdmin = useUpdateAdmin(); const updateAdmin = useUpdateAdmin();
@@ -91,13 +94,11 @@ const AdminListPage: React.FC = () => {
sorter: SorterResult<Admin> | SorterResult<Admin>[] sorter: SorterResult<Admin> | SorterResult<Admin>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
const roleColors: Record<string, string> = { const roleColors: Record<string, string> = {
@@ -207,12 +208,7 @@ const AdminListPage: React.FC = () => {
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
{/* Модальное окно создания */} {/* Модальное окно создания */}
+7 -11
View File
@@ -7,11 +7,14 @@ import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents'; import { useEvent } from '../../hooks/useEvents';
import { AuditRecord, AuditListParams } from '../../types/api'; import { AuditRecord, AuditListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const TABLE_KEY = 'audit';
const AuditPage: React.FC = () => { const AuditPage: React.FC = () => {
const [params, setParams] = useState<AuditListParams>({ limit: 20, offset: 0, sort: 'timestamp', order: 'desc' }); const [params, setParams] = useState<AuditListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'timestamp', order: 'desc' });
const { data, isLoading } = useAudit(params); const { data, isLoading } = useAudit(params);
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 }); const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
@@ -71,13 +74,11 @@ const AuditPage: React.FC = () => {
sorter: SorterResult<AuditRecord> | SorterResult<AuditRecord>[] sorter: SorterResult<AuditRecord> | SorterResult<AuditRecord>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
const handleDateChange = (dates: any) => { const handleDateChange = (dates: any) => {
@@ -226,12 +227,7 @@ const AuditPage: React.FC = () => {
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
</div> </div>
); );
+43 -11
View File
@@ -1,35 +1,67 @@
import React from 'react'; import React from 'react';
import { Form, Input, Button, Card, message } from 'antd'; import { Form, Input, Button, Card, message } from 'antd';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { isAxiosError } from 'axios';
import { useTranslation } from 'react-i18next';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { loginSchema, LoginFormValues } from '../../schemas/loginSchema';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const login = useAuthStore((s) => s.login); const login = useAuthStore((s) => s.login);
const { t } = useTranslation();
const { control, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginFormValues>({
resolver: zodResolver(loginSchema),
defaultValues: { email: '', password: '' },
});
const onFinish = async (values: { email: string; password: string }) => { const onFinish = async (values: LoginFormValues) => {
console.log('Form submitted', values);
try { try {
await login(values.email, values.password); await login(values.email, values.password);
navigate('/dashboard'); navigate('/dashboard');
} catch (error) { } catch (error) {
message.error('Ошибка входа'); if (isAxiosError(error)) {
const apiError = error.response?.data?.error;
if (typeof apiError === 'string') {
message.error(t(`login.${apiError}`, { defaultValue: apiError }));
return;
}
}
message.error(t('login.error'));
} }
}; };
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Card title="Вход" style={{ width: 400 }}> <Card title={t('login.title')} style={{ width: 400 }}>
<Form onFinish={onFinish} layout="vertical"> <Form layout="vertical" onFinish={handleSubmit(onFinish)}>
<Form.Item name="email" label="Email" rules={[{ required: true }]}> <Form.Item
<Input /> label={t('login.email')}
validateStatus={errors.email ? 'error' : ''}
help={errors.email?.message}
>
<Controller
name="email"
control={control}
render={({ field }) => <Input {...field} autoComplete="username" />}
/>
</Form.Item> </Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}> <Form.Item
<Input.Password /> label={t('login.password')}
validateStatus={errors.password ? 'error' : ''}
help={errors.password?.message}
>
<Controller
name="password"
control={control}
render={({ field }) => <Input.Password {...field} autoComplete="current-password" />}
/>
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" block> <Button type="primary" htmlType="submit" block loading={isSubmitting}>
Войти {t('login.submit')}
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
+7 -11
View File
@@ -8,9 +8,12 @@ import { useAdmin } from '../../hooks/useAdmins';
import { BannedWord } from '../../types/api'; import { BannedWord } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
const TABLE_KEY = 'banned-words';
const BannedWordsPage: React.FC = () => { const BannedWordsPage: React.FC = () => {
const [params, setParams] = useState<BannedWordListParams>({ limit: 20, offset: 0 }); const [params, setParams] = useState<BannedWordListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0 });
const { data, isLoading } = useBannedWords(params); const { data, isLoading } = useBannedWords(params);
const addWord = useAddBannedWord(); const addWord = useAddBannedWord();
const removeWord = useRemoveBannedWord(); const removeWord = useRemoveBannedWord();
@@ -31,13 +34,11 @@ const BannedWordsPage: React.FC = () => {
sorter: SorterResult<BannedWord> | SorterResult<BannedWord>[] sorter: SorterResult<BannedWord> | SorterResult<BannedWord>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
// Обработчик поиска // Обработчик поиска
@@ -126,12 +127,7 @@ const BannedWordsPage: React.FC = () => {
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
</div> </div>
); );
+36 -11
View File
@@ -5,10 +5,13 @@ import { Link, useNavigate } from 'react-router-dom';
import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '../../hooks/useCalendars'; import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '../../hooks/useCalendars';
import { Calendar, CalendarListParams } from '../../types/api'; import { Calendar, CalendarListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
const TABLE_KEY = 'calendars';
const CalendarListPage: React.FC = () => { const CalendarListPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [params, setParams] = useState<CalendarListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' }); const [params, setParams] = useState<CalendarListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
const { data, isLoading } = useCalendars(params); const { data, isLoading } = useCalendars(params);
const { data: stats } = useCalendarStats(); const { data: stats } = useCalendarStats();
const updateCalendar = useUpdateCalendar(); const updateCalendar = useUpdateCalendar();
@@ -101,13 +104,11 @@ const CalendarListPage: React.FC = () => {
sorter: SorterResult<Calendar> | SorterResult<Calendar>[] sorter: SorterResult<Calendar> | SorterResult<Calendar>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current || 1) - 1) * (pagination.pageSize || prev.limit || 20), }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
const columns: ColumnsType<Calendar> = [ const columns: ColumnsType<Calendar> = [
@@ -204,8 +205,37 @@ const CalendarListPage: React.FC = () => {
</Card> </Card>
</Col> </Col>
))} ))}
{Object.entries(stats.calendars_by_type || {}).map(([type, count]) => (
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
<Card>
<Statistic title={`Тип: ${type}`} value={count} />
</Card>
</Col>
))}
</Row> </Row>
)} )}
{stats?.top_calendars_by_rating && stats.top_calendars_by_rating.length > 0 && (
<Card title="Топ календарей по рейтингу" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={stats.top_calendars_by_rating}
columns={[
{
title: 'Название',
dataIndex: 'title',
key: 'title',
render: (title: string, record) => (
<Link to={`/calendars/${record.id}`}>{title || record.id}</Link>
),
},
{ title: 'Рейтинг', dataIndex: 'rating_avg', key: 'rating_avg' },
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
]}
/>
</Card>
)}
<Space style={{ marginBottom: 16 }} wrap> <Space style={{ marginBottom: 16 }} wrap>
<Input.Search <Input.Search
placeholder="Поиск" placeholder="Поиск"
@@ -241,12 +271,7 @@ const CalendarListPage: React.FC = () => {
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
<Modal <Modal
+132 -153
View File
@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { Card, Col, Row, Statistic, Spin, Alert, Table, Tag } from 'antd'; import { Card, Col, Row, Statistic, Spin, Alert, Button } from 'antd';
import { import {
UserOutlined, UserOutlined,
CalendarOutlined, CalendarOutlined,
@@ -8,146 +8,101 @@ import {
WarningOutlined, WarningOutlined,
BugOutlined, BugOutlined,
ClockCircleOutlined, ClockCircleOutlined,
DollarOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useDashboardStats } from '../../hooks/useDashboard'; import { useDashboardStats } from '../../hooks/useDashboard';
import type { ColumnsType } from 'antd/es/table'; import { useAuthStore } from '../../store/authStore';
import { AdminActivity } from '../../types/api'; import DailyLineChartCard from '../../components/DailyLineChartCard';
import { import StatisticSparklineCard from '../../components/StatisticSparklineCard';
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
AreaChart,
Area,
} from 'recharts';
const DashboardPage: React.FC = () => { const DashboardPage: React.FC = () => {
const { data, isLoading, error } = useDashboardStats(); const { data, isLoading, error } = useDashboardStats();
const role = useAuthStore((s) => s.user?.role);
const navigate = useNavigate();
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />; if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (error) return <Alert type="error" message="Ошибка загрузки статистики" />; if (error) return <Alert type="error" message="Ошибка загрузки статистики" />;
if (!data) return null; if (!data) return null;
const roleColors: Record<string, string> = { if (role === 'moderator') {
superadmin: 'red', return (
admin: 'blue', <div>
moderator: 'purple', <h2>Модерация</h2>
support: 'cyan', <Row gutter={[16, 16]}>
}; <Col xs={24} sm={8}>
<Card><Statistic title="Рассмотрено" value={data.reports_reviewed ?? 0} prefix={<WarningOutlined />} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Отклонено" value={data.reports_dismissed ?? 0} /></Card>
</Col>
<Col xs={24} sm={8}>
<Card><Statistic title="Ср. время (ч)" value={data.avg_report_resolution_h ?? 0} precision={1} prefix={<ClockCircleOutlined />} /></Card>
</Col>
</Row>
<Button type="primary" style={{ marginTop: 16 }} onClick={() => navigate('/reports')}>
Перейти к жалобам
</Button>
</div>
);
}
const adminColumns: ColumnsType<AdminActivity> = [ if (role === 'support') {
{ title: 'Email', dataIndex: 'email', key: 'email' }, return (
{ title: 'Ник', dataIndex: 'nickname', key: 'nickname' }, <div>
{ <h2>Поддержка</h2>
title: 'Роль', <Row gutter={[16, 16]}>
dataIndex: 'role', <Col xs={24} sm={8}>
key: 'role', <Card><Statistic title="Назначено открытых" value={data.tickets_assigned_open ?? 0} prefix={<BugOutlined />} /></Card>
render: (role: string) => ( </Col>
<Tag color={roleColors[role] || 'default'}>{role}</Tag> <Col xs={24} sm={8}>
), <Card><Statistic title="Всего назначено" value={data.tickets_assigned_total ?? 0} /></Card>
}, </Col>
{ title: 'Действий', dataIndex: 'actions', key: 'actions' }, <Col xs={24} sm={8}>
{ title: 'Последний вход', dataIndex: 'last_login', key: 'last_login' }, <Card><Statistic title="Ср. решение (ч)" value={data.avg_ticket_resolution_h ?? 0} precision={1} prefix={<ClockCircleOutlined />} /></Card>
]; </Col>
</Row>
<Button type="primary" style={{ marginTop: 16 }} onClick={() => navigate('/tickets')}>
Перейти к тикетам
</Button>
</div>
);
}
const eventsChartData = data.events_by_day?.map(item => ({ const registrationsSparkline = data.registrations_by_day?.map((item) => ({
date: item.date, date: item.date,
events: item.count, value: item.count,
})) || []; })) ?? [];
const registrationsChartData = data.registrations_by_day?.map(item => ({ const eventsSparkline = data.events_by_day?.map((item) => ({
date: item.date, date: item.date,
registrations: item.count, value: item.count,
})) || []; })) ?? [];
return ( return (
<div> <div>
<h2>Общая статистика</h2> <h2>Общая статистика</h2>
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{/* Пользователи с мини-графиком */}
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
<Card> <StatisticSparklineCard
<div style={{ display: 'flex', alignItems: 'center' }}> title="Пользователи"
<Statistic value={data.users_total}
title="Пользователи" prefix={<UserOutlined />}
value={data.users_total} data={registrationsSparkline}
prefix={<UserOutlined />} color="#52c41a"
style={{ flex: '0 0 auto', marginRight: 16 }} gradientId="colorRegistrations"
/> />
{registrationsChartData.length > 0 && (
<AreaChart
width={150}
height={50}
data={registrationsChartData}
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorRegistrations" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.4} />
<stop offset="95%" stopColor="#52c41a" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
<Area
type="monotone"
dataKey="registrations"
name="Регистрации"
stroke="#52c41a"
fill="url(#colorRegistrations)"
strokeWidth={2}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
/>
</AreaChart>
)}
</div>
</Card>
</Col> </Col>
{/* События с мини-графиком */}
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
<Card> <StatisticSparklineCard
<div style={{ display: 'flex', alignItems: 'center' }}> title="События"
<Statistic value={data.events_total}
title="События" prefix={<TeamOutlined />}
value={data.events_total} data={eventsSparkline}
prefix={<TeamOutlined />} color="#1890ff"
style={{ flex: '0 0 auto', marginRight: 16 }} gradientId="colorEvents"
/> />
{eventsChartData.length > 0 && (
<AreaChart
width={150}
height={50}
data={eventsChartData}
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorEvents" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1890ff" stopOpacity={0.4} />
<stop offset="95%" stopColor="#1890ff" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
<Area
type="monotone"
dataKey="events"
name="События"
stroke="#1890ff"
fill="url(#colorEvents)"
strokeWidth={2}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
/>
</AreaChart>
)}
</div>
</Card>
</Col> </Col>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} lg={6}>
@@ -190,47 +145,71 @@ const DashboardPage: React.FC = () => {
/> />
</Card> </Card>
</Col> </Col>
{data.pending_users_total !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Неподтверждённые" value={data.pending_users_total} prefix={<UserOutlined />} />
</Card>
</Col>
)}
{data.reports_pending !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы: pending" value={data.reports_pending} />
</Card>
</Col>
)}
{data.reports_reviewed !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы: reviewed" value={data.reports_reviewed} />
</Card>
</Col>
)}
{data.reports_dismissed !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы: dismissed" value={data.reports_dismissed} />
</Card>
</Col>
)}
{data.subscriptions_total !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Подписки" value={data.subscriptions_total} prefix={<DollarOutlined />} />
</Card>
</Col>
)}
{data.trial_subscriptions !== undefined && (
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Пробные подписки" value={data.trial_subscriptions} />
</Card>
</Col>
)}
</Row> </Row>
<Row gutter={[16, 16]} style={{ marginTop: 32 }}> <Row gutter={[16, 16]} style={{ marginTop: 32 }}>
<Col xs={24} lg={12}> <DailyLineChartCard title="События по дням" data={data.events_by_day} color="#1890ff" />
<Card title="События по дням"> <DailyLineChartCard title="Регистрации по дням" data={data.registrations_by_day} color="#52c41a" />
<ResponsiveContainer width="100%" height={300}> <DailyLineChartCard title="Отзывы по дням" data={data.reviews_by_day} color="#faad14" />
<LineChart data={eventsChartData}> <DailyLineChartCard title="Календари по дням" data={data.calendars_by_day} color="#722ed1" />
<CartesianGrid strokeDasharray="3 3" /> <DailyLineChartCard title="Жалобы по дням" data={data.reports_by_day} color="#ff4d4f" />
<XAxis dataKey="date" /> <DailyLineChartCard title="Тикеты по дням" data={data.tickets_by_day} color="#13c2c2" />
<YAxis allowDecimals={false} /> <DailyLineChartCard title="Подписки по дням" data={data.subscriptions_by_day} color="#eb2f96" />
<Tooltip /> <DailyLineChartCard title="Неподтверждённые по дням" data={data.pending_users_by_day} color="#fa8c16" />
<Legend />
<Line type="monotone" dataKey="events" stroke="#1890ff" name="События" />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="Регистрации по дням">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={registrationsChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="registrations" stroke="#52c41a" name="Регистрации" />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
</Row> </Row>
<h2 style={{ marginTop: 32 }}>Активность администраторов</h2> {role === 'superadmin' && (
<Table <Alert
columns={adminColumns} style={{ marginTop: 32 }}
dataSource={data.admin_activity} type="info"
rowKey="admin_id" showIcon
pagination={false} message="Активность администраторов"
size="small" description={<>Подробный журнал действий в разделе <Link to="/audit">Аудит</Link>.</>}
/> />
)}
</div> </div>
); );
}; };
+8 -2
View File
@@ -1,14 +1,20 @@
import React from 'react'; import React from 'react';
import { Button, Result } from 'antd'; import { Button, Result } from 'antd';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { getMenuSelectedKey, MENU_ITEMS } from '../../config/adminAccess';
const ForbiddenPage: React.FC = () => { const ForbiddenPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const sectionKey = getMenuSelectedKey(location.pathname);
const sectionLabel =
MENU_ITEMS.find((item) => item.key === sectionKey)?.label ?? 'этот раздел';
return ( return (
<Result <Result
status="403" status="403"
title="403" title="403"
subTitle="У вас нет доступа к этому разделу." subTitle={`У вас нет доступа к разделу «${sectionLabel}».`}
extra={ extra={
<Button type="primary" onClick={() => navigate('/dashboard')}> <Button type="primary" onClick={() => navigate('/dashboard')}>
На дашборд На дашборд
+37 -12
View File
@@ -1,15 +1,18 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, DatePicker, InputNumber, Card, Col, Row, Statistic } from 'antd'; import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, DatePicker, InputNumber, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons'; import { InfoCircleOutlined, EditOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent, useEventStats } from '../../hooks/useEvents'; import { useEvents, useUpdateEvent, useDeleteEvent, useEvent, useEventStats } from '../../hooks/useEvents';
import { Event, EventListParams } from '../../types/api'; import { Event, EventListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
const TABLE_KEY = 'events';
const EventListPage: React.FC = () => { const EventListPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [params, setParams] = useState<EventListParams>({ limit: 20, offset: 0, sort: 'id', order: 'asc' }); const [params, setParams] = useState<EventListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'id', order: 'asc' });
const { data, isLoading } = useEvents(params); const { data, isLoading } = useEvents(params);
const { data: stats } = useEventStats(); const { data: stats } = useEventStats();
const updateEvent = useUpdateEvent(); const updateEvent = useUpdateEvent();
@@ -111,13 +114,11 @@ const EventListPage: React.FC = () => {
sorter: SorterResult<Event> | SorterResult<Event>[] sorter: SorterResult<Event> | SorterResult<Event>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
const typeColors: Record<string, string> = { const typeColors: Record<string, string> = {
@@ -228,20 +229,44 @@ const EventListPage: React.FC = () => {
</Card> </Card>
</Col> </Col>
))} ))}
{Object.entries(stats.events_by_type || {}).map(([type, count]) => (
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
<Card>
<Statistic title={`Тип: ${type}`} value={count} />
</Card>
</Col>
))}
</Row> </Row>
)} )}
{stats?.top_events_by_rating && stats.top_events_by_rating.length > 0 && (
<Card title="Топ событий по рейтингу" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={stats.top_events_by_rating}
columns={[
{
title: 'Название',
dataIndex: 'title',
key: 'title',
render: (title: string, record) => (
<Link to={`/events/${record.id}`}>{title || record.id}</Link>
),
},
{ title: 'Рейтинг', dataIndex: 'rating_avg', key: 'rating_avg' },
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
]}
/>
</Card>
)}
<Table <Table
columns={columns} columns={columns}
dataSource={data?.data} dataSource={data?.data}
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
<Modal <Modal
+99 -30
View File
@@ -1,17 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Card, Col, Row, Select, Tabs } from 'antd'; import { Card, Col, Row, Select, Table, Tabs } from 'antd';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { useMetricsStore, NodeMetric } from '../../store/metricsStore'; import { useMetricsStore, NodeMetric } from '../../store/metricsStore';
import { monitoringApi } from '../../api/monitoringApi';
import AreaTrendChart from '../../components/AreaTrendChart';
const NODE_COLORS = ['#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2']; const NODE_COLORS = ['#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2'];
const TIME_RANGES = [ const TIME_RANGES = [
@@ -21,43 +13,73 @@ const TIME_RANGES = [
{ label: '1 час', value: 60 }, { label: '1 час', value: 60 },
]; ];
const bytesToMb = (value?: number) => (value ?? 0) / 1048576;
const formatTime = (ts: string) => dayjs(ts).format('HH:mm:ss'); const formatTime = (ts: string) => dayjs(ts).format('HH:mm:ss');
const buildChartData = (metrics: NodeMetric[]) => const buildChartData = (metrics: NodeMetric[]) =>
metrics.map((m) => ({ metrics.map((m) => ({
time: formatTime(m.timestamp), time: formatTime(m.timestamp),
cpu: m.cpu_utilization ?? 0, cpu: m.cpu_utilization ?? 0,
memoryMb: (m.memory_total ?? 0) / 1048576, memoryMb: bytesToMb(m.memory_total),
memoryProcessesMb: bytesToMb(m.memory_processes),
memoryAtomMb: bytesToMb(m.memory_atom),
memoryBinaryMb: bytesToMb(m.memory_binary),
memoryEtsMb: bytesToMb(m.memory_ets),
memoryAvailableMb: bytesToMb(m.memory_available),
ws: m.ws_connections ?? 0, ws: m.ws_connections ?? 0,
runQueue: m.run_queue ?? 0, runQueue: m.run_queue ?? 0,
processCount: m.process_count ?? 0,
activeSessions: m.active_sessions ?? 0,
ioIn: m.io_in ?? 0,
ioOut: m.io_out ?? 0,
mnesiaCommits: m.mnesia_commits ?? 0, mnesiaCommits: m.mnesia_commits ?? 0,
mnesiaFailures: m.mnesia_failures ?? 0, mnesiaFailures: m.mnesia_failures ?? 0,
tableSizes: m.table_sizes ?? {},
})); }));
const MetricChart: React.FC<{ data: ReturnType<typeof buildChartData>; lines: Array<{ key: string; color: string; name: string }> }> = ({ const MetricChart: React.FC<{
data, data: ReturnType<typeof buildChartData>;
lines, lines: Array<{ key: string; color: string; name: string }>;
}) => ( }> = ({ data, lines }) => (
<ResponsiveContainer width="100%" height={280}> <AreaTrendChart data={data} xKey="time" series={lines} />
<LineChart data={data}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" minTickGap={30} />
<YAxis />
<Tooltip />
<Legend />
{lines.map((line) => (
<Line key={line.key} type="monotone" dataKey={line.key} stroke={line.color} name={line.name} dot={false} />
))}
</LineChart>
</ResponsiveContainer>
); );
const MonitoringPage: React.FC = () => { const MonitoringPage: React.FC = () => {
const allHistory = useMetricsStore((s) => s.allHistory); const allHistory = useMetricsStore((s) => s.allHistory);
const current = useMetricsStore((s) => s.current); const current = useMetricsStore((s) => s.current);
const setAllHistory = useMetricsStore((s) => s.setAllHistory);
const filterVisibleHistory = useMetricsStore((s) => s.filterVisibleHistory); const filterVisibleHistory = useMetricsStore((s) => s.filterVisibleHistory);
const visibleHistory = useMetricsStore((s) => s.visibleHistory); const visibleHistory = useMetricsStore((s) => s.visibleHistory);
const [minutes, setMinutes] = useState(15); const [minutes, setMinutes] = useState(15);
const [loadingHistory, setLoadingHistory] = useState(false);
useEffect(() => {
let cancelled = false;
const loadHistory = async () => {
setLoadingHistory(true);
try {
const to = dayjs().toISOString();
const from = dayjs().subtract(minutes, 'minute').toISOString();
const metrics = await monitoringApi.getNodeMetrics(from, to);
if (!cancelled && metrics.length > 0) {
setAllHistory(metrics);
}
} catch {
// live WS дополняет историю; REST недоступен — не блокируем страницу
} finally {
if (!cancelled) {
setLoadingHistory(false);
}
}
};
loadHistory();
return () => {
cancelled = true;
};
}, [minutes, setAllHistory]);
useEffect(() => { useEffect(() => {
filterVisibleHistory(minutes); filterVisibleHistory(minutes);
@@ -83,6 +105,7 @@ const MonitoringPage: React.FC = () => {
const tabItems = allNodes.map((node, idx) => { const tabItems = allNodes.map((node, idx) => {
const nodeMetrics = metricsByNode.get(node) || []; const nodeMetrics = metricsByNode.get(node) || [];
const chartData = buildChartData(nodeMetrics); const chartData = buildChartData(nodeMetrics);
const latestTableSizes = chartData.at(-1)?.tableSizes ?? {};
const color = NODE_COLORS[idx % NODE_COLORS.length]; const color = NODE_COLORS[idx % NODE_COLORS.length];
return { return {
@@ -96,7 +119,21 @@ const MonitoringPage: React.FC = () => {
data={chartData} data={chartData}
lines={[ lines={[
{ key: 'cpu', color, name: 'CPU %' }, { key: 'cpu', color, name: 'CPU %' },
{ key: 'memoryMb', color: '#1890ff', name: 'Память МБ' }, { key: 'memoryMb', color: '#1890ff', name: 'Память всего' },
{ key: 'memoryAvailableMb', color: '#13c2c2', name: 'Память доступно' },
]}
/>
</Card>
</Col>
<Col span={24}>
<Card title="Память по типам (МБ)">
<MetricChart
data={chartData}
lines={[
{ key: 'memoryProcessesMb', color: '#1677ff', name: 'Processes' },
{ key: 'memoryAtomMb', color: '#52c41a', name: 'Atom' },
{ key: 'memoryBinaryMb', color: '#fa8c16', name: 'Binary' },
{ key: 'memoryEtsMb', color: '#eb2f96', name: 'ETS' },
]} ]}
/> />
</Card> </Card>
@@ -108,6 +145,8 @@ const MonitoringPage: React.FC = () => {
lines={[ lines={[
{ key: 'ws', color: '#52c41a', name: 'WS' }, { key: 'ws', color: '#52c41a', name: 'WS' },
{ key: 'runQueue', color: '#fa8c16', name: 'Run queue' }, { key: 'runQueue', color: '#fa8c16', name: 'Run queue' },
{ key: 'processCount', color: '#722ed1', name: 'Processes' },
{ key: 'activeSessions', color: '#1677ff', name: 'Sessions' },
]} ]}
/> />
</Card> </Card>
@@ -123,6 +162,34 @@ const MonitoringPage: React.FC = () => {
/> />
</Card> </Card>
</Col> </Col>
<Col span={24}>
<Card title="I/O">
<MetricChart
data={chartData}
lines={[
{ key: 'ioIn', color: '#1677ff', name: 'IO in' },
{ key: 'ioOut', color: '#52c41a', name: 'IO out' },
]}
/>
</Card>
</Col>
<Col span={24}>
<Card title="Размеры таблиц Mnesia (последняя точка)">
<Table
size="small"
pagination={false}
rowKey="table"
dataSource={Object.entries(latestTableSizes).map(([table, size]) => ({
table,
size,
}))}
columns={[
{ title: 'Таблица', dataIndex: 'table', key: 'table' },
{ title: 'Записей', dataIndex: 'size', key: 'size' },
]}
/>
</Card>
</Col>
</Row> </Row>
), ),
}; };
@@ -141,7 +208,9 @@ const MonitoringPage: React.FC = () => {
</div> </div>
{allNodes.length === 0 ? ( {allNodes.length === 0 ? (
<Card>Ожидание метрик по WebSocket</Card> <Card loading={loadingHistory}>
{loadingHistory ? 'Загрузка метрик…' : 'Ожидание метрик по WebSocket…'}
</Card>
) : ( ) : (
<Tabs items={tabItems} /> <Tabs items={tabItems} />
)} )}
+39 -38
View File
@@ -2,19 +2,44 @@ import React, { useState } from 'react';
import { Card, Descriptions, Spin, Button, Form, Input, Select, Tag, message, Space, Avatar } from 'antd'; import { Card, Descriptions, Spin, Button, Form, Input, Select, Tag, message, Space, Avatar } from 'antd';
import { UserOutlined } from '@ant-design/icons'; import { UserOutlined } from '@ant-design/icons';
import { useAuthStore } from '../../store/authStore'; import { useAuthStore } from '../../store/authStore';
import { useUpdateAdmin } from '../../hooks/useAdmins'; import { useUpdateProfile } from '../../hooks/useProfile';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
const ProfilePage: React.FC = () => { const ProfilePage: React.FC = () => {
const { user } = useAuthStore(); const { user } = useAuthStore();
const updateAdmin = useUpdateAdmin(); const updateProfile = useUpdateProfile();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [form] = Form.useForm(); const [form] = Form.useForm();
// Проверка, является ли текущий пользователь superadmin // Редактирование своего профиля доступно всем ролям (PUT /v1/admin/me)
const isSuperadmin = user?.role === 'superadmin'; const handleSave = () => {
if (!user) return;
form.validateFields().then((values) => {
const allowed = ['nickname', 'timezone', 'language', 'phone', 'avatar_url', 'preferences'] as const;
const payload = Object.fromEntries(
Object.entries(values).filter(([key, v]) =>
(allowed as readonly string[]).includes(key) && v !== '' && v !== undefined && v !== null
)
);
if (payload.preferences) {
try {
payload.preferences = JSON.parse(payload.preferences as string);
} catch {
message.error('Поле «Настройки» должно быть валидным JSON');
return;
}
}
updateProfile.mutate(
payload,
{
onSuccess: () => {
setEditing(false);
},
}
);
});
};
// Заполняем форму при входе в режим редактирования
const startEditing = () => { const startEditing = () => {
if (!user) return; if (!user) return;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val); const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
@@ -30,32 +55,6 @@ const ProfilePage: React.FC = () => {
setEditing(true); setEditing(true);
}; };
const handleSave = () => {
if (!user || !isSuperadmin) return;
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
if (payload.preferences) {
try {
payload.preferences = JSON.parse(payload.preferences as string);
} catch {
message.error('Поле «Настройки» должно быть валидным JSON');
return;
}
}
updateAdmin.mutate(
{ id: user.id, data: payload },
{
onSuccess: () => {
message.success('Профиль обновлён');
setEditing(false);
},
}
);
});
};
const isBadValue = (val: any) => const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
@@ -72,7 +71,11 @@ const ProfilePage: React.FC = () => {
{!editing ? ( {!editing ? (
<> <>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}> <div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<Avatar size={64} icon={<UserOutlined />} src={isBadValue(user.avatar_url) ? '-' : <a href={user.avatar_url || undefined} target="_blank" rel="noreferrer">{user.avatar_url}</a>} /> <Avatar
size={64}
icon={<UserOutlined />}
src={!isBadValue(user.avatar_url) ? user.avatar_url! : undefined}
/>
<div> <div>
<h3>{!isBadValue(user.nickname) ? user.nickname : user.email}</h3> <h3>{!isBadValue(user.nickname) ? user.nickname : user.email}</h3>
<Tag <Tag
@@ -122,11 +125,9 @@ const ProfilePage: React.FC = () => {
</Descriptions.Item> </Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item> <Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
</Descriptions> </Descriptions>
{isSuperadmin && ( <Button type="primary" style={{ marginTop: 16 }} onClick={startEditing}>
<Button type="primary" style={{ marginTop: 16 }} onClick={startEditing}> Редактировать
Редактировать </Button>
</Button>
)}
</> </>
) : ( ) : (
<Form form={form} layout="vertical" onFinish={handleSave}> <Form form={form} layout="vertical" onFinish={handleSave}>
@@ -187,7 +188,7 @@ const ProfilePage: React.FC = () => {
<Input.TextArea rows={4} placeholder='{"key": "value"}' /> <Input.TextArea rows={4} placeholder='{"key": "value"}' />
</Form.Item> </Form.Item>
<Space> <Space>
<Button type="primary" htmlType="submit" loading={updateAdmin.isPending}> <Button type="primary" htmlType="submit" loading={updateProfile.isPending}>
Сохранить Сохранить
</Button> </Button>
<Button onClick={() => setEditing(false)}>Отмена</Button> <Button onClick={() => setEditing(false)}>Отмена</Button>
+1 -1
View File
@@ -20,7 +20,7 @@ const ReportDetailPage: React.FC = () => {
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || ''); const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || ''); const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetId || ''); const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetType === 'event' ? targetId || '' : '');
const handleStatusChange = (status: 'reviewed' | 'dismissed') => { const handleStatusChange = (status: 'reviewed' | 'dismissed') => {
Modal.confirm({ Modal.confirm({
+39 -13
View File
@@ -8,10 +8,14 @@ import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins'; import { useAdmin } from '../../hooks/useAdmins';
import { Report, ReportListParams } from '../../types/api'; import { Report, ReportListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
const TABLE_KEY = 'reports';
const ReportListPage: React.FC = () => { const ReportListPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [params, setParams] = useState<ReportListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' }); const [params, setParams] = useState<ReportListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
const { data, isLoading } = useReports(params); const { data, isLoading } = useReports(params);
const { data: stats } = useReportStats(); const { data: stats } = useReportStats();
const updateReport = useUpdateReport(); const updateReport = useUpdateReport();
@@ -28,7 +32,7 @@ const ReportListPage: React.FC = () => {
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || ''); const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || ''); const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetId || ''); const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetType === 'event' ? targetId || '' : '');
const handleViewDetails = (report: Report) => { const handleViewDetails = (report: Report) => {
setDetailModal({ open: true, report }); setDetailModal({ open: true, report });
@@ -40,13 +44,11 @@ const ReportListPage: React.FC = () => {
sorter: SorterResult<Report> | SorterResult<Report>[] sorter: SorterResult<Report> | SorterResult<Report>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
// Цвета для типа цели // Цвета для типа цели
@@ -141,7 +143,7 @@ const ReportListPage: React.FC = () => {
sorter: true, sorter: true,
render: (status: string) => ( render: (status: string) => (
<Tag color={status === 'pending' ? 'orange' : status === 'reviewed' ? 'green' : 'default'}> <Tag color={status === 'pending' ? 'orange' : status === 'reviewed' ? 'green' : 'default'}>
{status} {formatStatusLabel(status)}
</Tag> </Tag>
), ),
}, },
@@ -219,20 +221,44 @@ const ReportListPage: React.FC = () => {
</Card> </Card>
</Col> </Col>
))} ))}
{Object.entries(stats.reports_by_target_type || {}).map(([type, count]) => (
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
<Card>
<Statistic title={`Цель: ${type}`} value={count} />
</Card>
</Col>
))}
</Row> </Row>
)} )}
{stats?.top_targets_by_reports && stats.top_targets_by_reports.length > 0 && (
<Card title="Топ целей по жалобам" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.target_type}-${r.target_id}`}
dataSource={stats.top_targets_by_reports}
columns={[
{ title: 'Тип', dataIndex: 'target_type', key: 'target_type' },
{
title: 'ID цели',
dataIndex: 'target_id',
key: 'target_id',
render: (id: string, record) => (
<Link to={`/${record.target_type}s/${id}`}>{id}</Link>
),
},
{ title: 'Жалоб', dataIndex: 'report_count', key: 'report_count' },
]}
/>
</Card>
)}
<Table <Table
columns={columns} columns={columns}
dataSource={data?.data} dataSource={data?.data}
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
<Modal <Modal
+38 -12
View File
@@ -7,10 +7,14 @@ import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents'; import { useEvent } from '../../hooks/useEvents';
import { Review, ReviewListParams } from '../../types/api'; import { Review, ReviewListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
const TABLE_KEY = 'reviews';
const ReviewListPage: React.FC = () => { const ReviewListPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [params, setParams] = useState<ReviewListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' }); const [params, setParams] = useState<ReviewListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'created_at', order: 'desc' });
const { data, isLoading } = useReviews(params); const { data, isLoading } = useReviews(params);
const { data: stats } = useReviewStats(); const { data: stats } = useReviewStats();
const updateReview = useUpdateReview(); const updateReview = useUpdateReview();
@@ -97,13 +101,11 @@ const ReviewListPage: React.FC = () => {
sorter: SorterResult<Review> | SorterResult<Review>[] sorter: SorterResult<Review> | SorterResult<Review>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
@@ -205,7 +207,7 @@ const ReviewListPage: React.FC = () => {
sorter: true, sorter: true,
render: (status: string) => ( render: (status: string) => (
<Tag color={status === 'visible' ? 'green' : status === 'hidden' ? 'orange' : 'red'}> <Tag color={status === 'visible' ? 'green' : status === 'hidden' ? 'orange' : 'red'}>
{status} {formatStatusLabel(status)}
</Tag> </Tag>
), ),
}, },
@@ -253,8 +255,37 @@ const ReviewListPage: React.FC = () => {
</Card> </Card>
</Col> </Col>
))} ))}
{Object.entries(stats.reviews_by_target_type || {}).map(([type, count]) => (
<Col xs={12} sm={6} md={4} key={`type-${type}`}>
<Card>
<Statistic title={`Цель: ${type}`} value={count} />
</Card>
</Col>
))}
</Row> </Row>
)} )}
{stats?.top_targets_by_reviews && stats.top_targets_by_reviews.length > 0 && (
<Card title="Топ целей по отзывам" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.target_type}-${r.target_id}`}
dataSource={stats.top_targets_by_reviews}
columns={[
{ title: 'Тип', dataIndex: 'target_type', key: 'target_type' },
{
title: 'ID цели',
dataIndex: 'target_id',
key: 'target_id',
render: (id: string, record) => (
<Link to={`/${record.target_type}s/${id}`}>{id}</Link>
),
},
{ title: 'Отзывов', dataIndex: 'review_count', key: 'review_count' },
]}
/>
</Card>
)}
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}> <Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
Скрыть выбранные Скрыть выбранные
@@ -276,12 +307,7 @@ const ReviewListPage: React.FC = () => {
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
{/* Модальное окно индивидуального редактирования */} {/* Модальное окно индивидуального редактирования */}
@@ -7,9 +7,12 @@ import { useUser } from '../../hooks/useUsers';
import { Subscription, SubscriptionListParams } from '../../types/api'; import { Subscription, SubscriptionListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
const TABLE_KEY = 'subscriptions';
const SubscriptionListPage: React.FC = () => { const SubscriptionListPage: React.FC = () => {
const [params, setParams] = useState<SubscriptionListParams>({ limit: 20, offset: 0 }); const [params, setParams] = useState<SubscriptionListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0 });
const { data, isLoading } = useSubscriptions(params); const { data, isLoading } = useSubscriptions(params);
const { data: stats } = useSubscriptionStats(); const { data: stats } = useSubscriptionStats();
const updateSubscription = useUpdateSubscription(); const updateSubscription = useUpdateSubscription();
@@ -77,13 +80,11 @@ const SubscriptionListPage: React.FC = () => {
sorter: SorterResult<Subscription> | SorterResult<Subscription>[] sorter: SorterResult<Subscription> | SorterResult<Subscription>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined; const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
@@ -198,8 +199,31 @@ const SubscriptionListPage: React.FC = () => {
</Card> </Card>
</Col> </Col>
))} ))}
{Object.entries(stats.subscriptions_by_plan || {}).map(([plan, count]) => (
<Col xs={12} sm={6} md={4} key={`plan-${plan}`}>
<Card>
<Statistic title={`План: ${plan}`} value={count} />
</Card>
</Col>
))}
</Row> </Row>
)} )}
{stats?.ending_paid_subscriptions && stats.ending_paid_subscriptions.length > 0 && (
<Card title="Заканчивающиеся платные подписки" style={{ marginBottom: 16 }}>
<Table
size="small"
pagination={false}
rowKey="id"
dataSource={stats.ending_paid_subscriptions}
columns={[
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: 'Пользователь', dataIndex: 'user_id', key: 'user_id' },
{ title: 'План', dataIndex: 'plan', key: 'plan' },
{ title: 'Истекает', dataIndex: 'expires_at', key: 'expires_at' },
]}
/>
</Card>
)}
<Space style={{ marginBottom: 16 }}> <Space style={{ marginBottom: 16 }}>
<Select <Select
allowClear allowClear
@@ -230,12 +254,7 @@ const SubscriptionListPage: React.FC = () => {
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
<Modal <Modal
+9 -12
View File
@@ -6,10 +6,14 @@ import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTick
import { useAdmin } from '../../hooks/useAdmins'; import { useAdmin } from '../../hooks/useAdmins';
import { Ticket, TicketListParams } from '../../types/api'; import { Ticket, TicketListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
const TABLE_KEY = 'tickets';
const TicketListPage: React.FC = () => { const TicketListPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [params, setParams] = useState<TicketListParams>({ limit: 20, offset: 0, sort: 'last_seen', order: 'desc' }); const [params, setParams] = useState<TicketListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'last_seen', order: 'desc' });
const { data, isLoading } = useTickets(params); const { data, isLoading } = useTickets(params);
const deleteTicket = useDeleteTicket(); const deleteTicket = useDeleteTicket();
const { data: stats } = useTicketStats(); const { data: stats } = useTicketStats();
@@ -33,13 +37,11 @@ const TicketListPage: React.FC = () => {
sorter: SorterResult<Ticket> | SorterResult<Ticket>[] sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
const columns: ColumnsType<Ticket> = [ const columns: ColumnsType<Ticket> = [
@@ -78,7 +80,7 @@ const TicketListPage: React.FC = () => {
status === 'open' ? 'red' : status === 'open' ? 'red' :
status === 'in_progress' ? 'blue' : status === 'in_progress' ? 'blue' :
status === 'resolved' ? 'green' : 'default'; status === 'resolved' ? 'green' : 'default';
return <Tag color={color}>{status}</Tag>; return <Tag color={color}>{formatStatusLabel(status)}</Tag>;
}, },
}, },
{ {
@@ -154,12 +156,7 @@ const TicketListPage: React.FC = () => {
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
</div> </div>
); );
+16 -12
View File
@@ -5,10 +5,14 @@ import { useNavigate } from 'react-router-dom';
import { useUsers, useUpdateUser, useDeleteUser, useUser, useUserStats } from '../../hooks/useUsers'; import { useUsers, useUpdateUser, useDeleteUser, useUser, useUserStats } from '../../hooks/useUsers';
import { User, UserListParams } from '../../types/api'; import { User, UserListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface'; import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
import { formatStatusLabel } from '../../utils/statusLabels';
const TABLE_KEY = 'users';
const UserListPage: React.FC = () => { const UserListPage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [params, setParams] = useState<UserListParams>({ limit: 20, offset: 0, sort: 'id', order: 'asc' }); const [params, setParams] = useState<UserListParams>({ limit: getSavedPageSize(TABLE_KEY), offset: 0, sort: 'id', order: 'asc' });
const { data, isLoading } = useUsers(params); const { data, isLoading } = useUsers(params);
const { data: stats } = useUserStats(); const { data: stats } = useUserStats();
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
@@ -155,13 +159,11 @@ const UserListPage: React.FC = () => {
sorter: SorterResult<User> | SorterResult<User>[] sorter: SorterResult<User> | SorterResult<User>[]
) => { ) => {
const s = Array.isArray(sorter) ? sorter[0] : sorter; const s = Array.isArray(sorter) ? sorter[0] : sorter;
setParams(prev => ({ setParams(prev => mergeTablePagination({
...prev, ...prev,
sort: s.field as string, sort: s.field as string,
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined, order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
offset: ((pagination.current - 1) * pagination.pageSize) || 0, }, pagination, TABLE_KEY));
limit: pagination.pageSize || prev.limit,
}));
}; };
const columns: ColumnsType<User> = [ const columns: ColumnsType<User> = [
@@ -202,7 +204,7 @@ const UserListPage: React.FC = () => {
sorter: true, sorter: true,
render: (status: string) => ( render: (status: string) => (
<Tag color={status === 'active' ? 'green' : status === 'frozen' ? 'orange' : 'red'}> <Tag color={status === 'active' ? 'green' : status === 'frozen' ? 'orange' : 'red'}>
{status} {formatStatusLabel(status)}
</Tag> </Tag>
), ),
}, },
@@ -267,6 +269,13 @@ const UserListPage: React.FC = () => {
</Card> </Card>
</Col> </Col>
))} ))}
{Object.entries(stats.users_by_role || {}).map(([role, count]) => (
<Col xs={12} sm={6} md={4} key={`role-${role}`}>
<Card>
<Statistic title={`Роль: ${role}`} value={count} />
</Card>
</Col>
))}
</Row> </Row>
)} )}
<Table <Table
@@ -275,12 +284,7 @@ const UserListPage: React.FC = () => {
rowKey="id" rowKey="id"
loading={isLoading} loading={isLoading}
onChange={handleTableChange} onChange={handleTableChange}
pagination={{ pagination={getTablePagination(params, data?.total)}
total: data?.total,
current: (params.offset || 0) / (params.limit || 20) + 1,
pageSize: params.limit || 20,
showSizeChanger: false,
}}
/> />
{/* Модальное окно редактирования */} {/* Модальное окно редактирования */}
+8
View File
@@ -0,0 +1,8 @@
import { z } from 'zod';
export const loginSchema = z.object({
email: z.string().min(1, 'Email обязателен').email('Некорректный email'),
password: z.string().min(1, 'Пароль обязателен'),
});
export type LoginFormValues = z.infer<typeof loginSchema>;
+3
View File
@@ -13,6 +13,7 @@ interface AuthState {
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
checkAuth: () => Promise<void>; checkAuth: () => Promise<void>;
setUser: (user: Admin) => void;
} }
export const useAuthStore = create<AuthState>((set) => ({ export const useAuthStore = create<AuthState>((set) => ({
@@ -69,4 +70,6 @@ export const useAuthStore = create<AuthState>((set) => ({
}); });
} }
}, },
setUser: (user) => set({ user }),
})); }));
+4
View File
@@ -56,6 +56,10 @@ export const useMetricsStore = create<MetricsState>((set, get) => ({
addToAllHistory: (metric) => addToAllHistory: (metric) =>
set((state) => { set((state) => {
const key = `${metric.node}|${metric.timestamp}`;
if (state.allHistory.some((m) => `${m.node}|${m.timestamp}` === key)) {
return state;
}
const newAll = [...state.allHistory, metric].slice(-MAX_HISTORY); const newAll = [...state.allHistory, metric].slice(-MAX_HISTORY);
return { allHistory: newAll }; return { allHistory: newAll };
}), }),
+18 -1
View File
@@ -328,16 +328,33 @@ export interface AuditListParams {
// ===================== Dashboard Stats ===================== // ===================== Dashboard Stats =====================
export interface DashboardStats { export interface DashboardStats {
users_total: number; users_total: number;
pending_users_total?: number;
events_total: number; events_total: number;
reviews_total: number; reviews_total: number;
calendars_total: number; calendars_total: number;
reports_total: number; reports_total: number;
reports_pending?: number;
reports_reviewed?: number;
reports_dismissed?: number;
tickets_total: number; tickets_total: number;
tickets_open: number; tickets_open: number;
subscriptions_total?: number;
trial_subscriptions?: number;
avg_ticket_resolution_h: number; avg_ticket_resolution_h: number;
admin_activity: AdminActivity[]; admin_activity?: AdminActivity[];
events_by_day: DayCount[]; events_by_day: DayCount[];
registrations_by_day: DayCount[]; registrations_by_day: DayCount[];
reviews_by_day?: DayCount[];
calendars_by_day?: DayCount[];
reports_by_day?: DayCount[];
tickets_by_day?: DayCount[];
subscriptions_by_day?: DayCount[];
pending_users_by_day?: DayCount[];
avg_report_resolution_h?: number;
events_moderated?: number;
// support
tickets_assigned_open?: number;
tickets_assigned_total?: number;
} }
export interface AdminActivity { export interface AdminActivity {
+14
View File
@@ -0,0 +1,14 @@
/** Маршруты к сущностям по target_type (жалобы, WS-уведомления). */
const ENTITY_PATH: Record<string, string> = {
event: 'events',
calendar: 'calendars',
review: 'reviews',
user: 'users',
report: 'reports',
ticket: 'tickets',
};
export function getEntityDetailPath(type: string | undefined, id: string): string {
const segment = type ? ENTITY_PATH[type] ?? `${type}s` : 'unknown';
return `/${segment}/${id}`;
}
+38
View File
@@ -0,0 +1,38 @@
const STATUS_LABELS: Record<string, Record<string, string>> = {
ru: {
active: 'Активен',
frozen: 'Заморожен',
deleted: 'Удалён',
pending: 'Ожидает',
reviewed: 'Рассмотрено',
dismissed: 'Отклонено',
open: 'Открыт',
in_progress: 'В работе',
resolved: 'Решён',
closed: 'Закрыт',
visible: 'Видимый',
hidden: 'Скрыт',
cancelled: 'Отменён',
completed: 'Завершён',
},
en: {
active: 'Active',
frozen: 'Frozen',
deleted: 'Deleted',
pending: 'Pending',
reviewed: 'Reviewed',
dismissed: 'Dismissed',
open: 'Open',
in_progress: 'In progress',
resolved: 'Resolved',
closed: 'Closed',
visible: 'Visible',
hidden: 'Hidden',
cancelled: 'Cancelled',
completed: 'Completed',
},
};
export function formatStatusLabel(status: string, locale: 'ru' | 'en' = 'ru'): string {
return STATUS_LABELS[locale]?.[status] ?? status;
}
+44
View File
@@ -0,0 +1,44 @@
export const TABLE_PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const;
export const DEFAULT_TABLE_PAGE_SIZE = 20;
const STORAGE_PREFIX = 'admin.table.';
export function getSavedPageSize(tableKey: string, fallback = DEFAULT_TABLE_PAGE_SIZE): number {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${tableKey}.pageSize`);
const size = raw ? Number(raw) : fallback;
return (TABLE_PAGE_SIZE_OPTIONS as readonly number[]).includes(size) ? size : fallback;
}
export function savePageSize(tableKey: string, size: number): void {
localStorage.setItem(`${STORAGE_PREFIX}${tableKey}.pageSize`, String(size));
}
export function getTablePagination(
params: { offset?: number; limit?: number },
total?: number
) {
const pageSize = params.limit || DEFAULT_TABLE_PAGE_SIZE;
return {
total,
current: Math.floor((params.offset || 0) / pageSize) + 1,
pageSize,
showSizeChanger: true,
pageSizeOptions: [...TABLE_PAGE_SIZE_OPTIONS],
};
}
export function mergeTablePagination<T extends { offset?: number; limit?: number }>(
prev: T,
pagination: { current?: number; pageSize?: number },
tableKey: string
): T {
const pageSize = pagination.pageSize || prev.limit || DEFAULT_TABLE_PAGE_SIZE;
if (pagination.pageSize && pagination.pageSize !== prev.limit) {
savePageSize(tableKey, pageSize);
}
return {
...prev,
offset: ((pagination.current ?? 1) - 1) * pageSize,
limit: pageSize,
};
}