Compare commits
22 Commits
db1417332d
...
de379e28bf
| Author | SHA1 | Date | |
|---|---|---|---|
| de379e28bf | |||
| d056b63e1b | |||
| 2056333529 | |||
| a1bd11afee | |||
| bc8ec9aeea | |||
| 874d9099de | |||
| bc20deac23 | |||
| fd825ddfd7 | |||
| 755d22cf48 | |||
| 72f973bbd1 | |||
| 190cca7e6b | |||
| 61da5b6d3a | |||
| da3fcbb6b0 | |||
| 8adf982587 | |||
| b7fb56b8cc | |||
| 96fb44c770 | |||
| 4b8069ad74 | |||
| b30607df3f | |||
| 9ff894988f | |||
| 9deaa34a6e | |||
| 7a2467370a | |||
| 28404eb7be |
Generated
+2696
-31
File diff suppressed because it is too large
Load Diff
@@ -16,4 +16,8 @@ export const authApi = {
|
||||
const { data } = await apiClient.get('/v1/admin/me');
|
||||
return data;
|
||||
},
|
||||
updateMe: async (payload: Partial<Admin>): Promise<Admin> => {
|
||||
const { data } = await apiClient.put('/v1/admin/me', payload);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
+9
-1
@@ -10,6 +10,9 @@ const apiClient = axios.create({
|
||||
|
||||
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
const isLoginRequest = (config?: InternalAxiosRequestConfig) =>
|
||||
Boolean(config?.url?.includes('/v1/admin/login'));
|
||||
|
||||
let isRefreshing = false;
|
||||
let pendingRequests: Array<{
|
||||
resolve: (token: string) => void;
|
||||
@@ -45,7 +48,12 @@ apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -52,3 +52,31 @@ export function filterMenuByRole<T extends { roles?: AdminRole[] }>(
|
||||
): T[] {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useMetricsStore } from '../store/metricsStore';
|
||||
import { canAccessRoute, canReceiveWsNotification } from '../config/adminAccess';
|
||||
|
||||
type WsMessage = {
|
||||
type: 'report_created' | 'ticket_created' | 'node_metric';
|
||||
@@ -11,10 +12,17 @@ type WsMessage = {
|
||||
timestamp?: number;
|
||||
};
|
||||
|
||||
const wsLog = (...args: unknown[]) => {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log(...args);
|
||||
}
|
||||
};
|
||||
|
||||
export const useAdminWebSocket = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
|
||||
const role = useAuthStore((s) => s.user?.role);
|
||||
const setCurrentMetric = useMetricsStore((s) => s.setCurrent);
|
||||
const addToAllHistory = useMetricsStore((s) => s.addToAllHistory);
|
||||
|
||||
@@ -59,6 +67,7 @@ export const useAdminWebSocket = () => {
|
||||
|
||||
shouldReconnectRef.current = true;
|
||||
let isMounted = true;
|
||||
const sessionStartSecondsRef = { current: 0 };
|
||||
|
||||
const connect = () => {
|
||||
if (!isMounted || !shouldReconnectRef.current) return;
|
||||
@@ -74,9 +83,14 @@ export const useAdminWebSocket = () => {
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!isMounted || !shouldReconnectRef.current) return;
|
||||
console.log('[WS] Connected');
|
||||
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
|
||||
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
|
||||
sessionStartSecondsRef.current = Math.floor(Date.now() / 1000);
|
||||
wsLog('[WS] Connected');
|
||||
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(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
@@ -89,7 +103,7 @@ export const useAdminWebSocket = () => {
|
||||
if (!isMounted) return;
|
||||
try {
|
||||
const msg: WsMessage = JSON.parse(event.data);
|
||||
console.log('[WS] Message:', msg);
|
||||
wsLog('[WS] Message:', msg);
|
||||
|
||||
if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) {
|
||||
return;
|
||||
@@ -99,9 +113,19 @@ export const useAdminWebSocket = () => {
|
||||
}
|
||||
|
||||
if (msg.type === 'report_created' || msg.type === 'ticket_created') {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('admin-ws-message', { detail: msg })
|
||||
);
|
||||
if (!canReceiveWsNotification(msg.type, role)) {
|
||||
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') {
|
||||
queryClient.invalidateQueries({ queryKey: ['reports'] });
|
||||
} else if (msg.type === 'ticket_created') {
|
||||
@@ -113,13 +137,15 @@ export const useAdminWebSocket = () => {
|
||||
addToAllHistory(msg.data);
|
||||
}
|
||||
} 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) => {
|
||||
if (!isMounted || !shouldReconnectRef.current) return;
|
||||
console.log('[WS] Disconnected:', event.reason);
|
||||
wsLog('[WS] Disconnected:', event.reason);
|
||||
clearTimers();
|
||||
wsRef.current = null;
|
||||
reconnectTimeoutRef.current = setTimeout(connect, 5000);
|
||||
@@ -127,7 +153,9 @@ export const useAdminWebSocket = () => {
|
||||
|
||||
ws.onerror = (error) => {
|
||||
if (!isMounted) return;
|
||||
console.error('[WS] Error:', error);
|
||||
if (import.meta.env.DEV) {
|
||||
console.error('[WS] Error:', error);
|
||||
}
|
||||
ws.close();
|
||||
};
|
||||
};
|
||||
@@ -139,5 +167,5 @@ export const useAdminWebSocket = () => {
|
||||
shouldReconnectRef.current = false;
|
||||
closeSocket();
|
||||
};
|
||||
}, [isAuthenticated, accessToken, queryClient, setCurrentMetric, addToAllHistory]);
|
||||
}, [isAuthenticated, accessToken, role, queryClient, setCurrentMetric, addToAllHistory]);
|
||||
};
|
||||
|
||||
@@ -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('Ошибка обновления профиля'),
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -23,7 +23,9 @@ import {
|
||||
CloudServerOutlined,
|
||||
} from '@ant-design/icons';
|
||||
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 { Text } = Typography;
|
||||
@@ -35,6 +37,7 @@ const AdminLayout: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
const { token: { colorBgContainer } } = theme.useToken();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
@@ -45,50 +48,45 @@ const AdminLayout: React.FC = () => {
|
||||
const handler = (event: CustomEvent) => {
|
||||
const msg = event.detail;
|
||||
if (msg.type === 'report_created') {
|
||||
if (!canReceiveWsNotification('report_created', user?.role)) {
|
||||
return;
|
||||
}
|
||||
const { report_id, target_type, target_id, reason } = msg.data || {};
|
||||
const targetPath = target_id ? getEntityDetailPath(target_type, target_id) : undefined;
|
||||
notification.info({
|
||||
title: 'Новая жалоба',
|
||||
description: (
|
||||
<span>
|
||||
Жалоба{' '}
|
||||
{report_id ? (
|
||||
<a href={`/reports/${report_id}`} onClick={(e) => { e.preventDefault(); navigate(`/reports/${report_id}`); }} style={{ fontWeight: 600 }}>
|
||||
#{report_id}
|
||||
</a>
|
||||
) : ('#?')}{' '}
|
||||
на {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>
|
||||
),
|
||||
key: report_id ? `report-${report_id}` : `report-${Date.now()}`,
|
||||
message: t('ws.newReport'),
|
||||
description: [
|
||||
report_id ? `Жалоба #${report_id}` : 'Жалоба',
|
||||
target_type ? `на ${target_type}` : '',
|
||||
target_id ?? '',
|
||||
reason ? `(${reason})` : '',
|
||||
].filter(Boolean).join(' '),
|
||||
placement: 'topRight',
|
||||
onClick: () => {
|
||||
if (report_id) navigate(`/reports/${report_id}`);
|
||||
else if (targetPath) navigate(targetPath);
|
||||
},
|
||||
});
|
||||
} else if (msg.type === 'ticket_created') {
|
||||
if (!canReceiveWsNotification('ticket_created', user?.role)) {
|
||||
return;
|
||||
}
|
||||
const { ticket_id } = msg.data || {};
|
||||
notification.info({
|
||||
title: 'Новый тикет',
|
||||
description: (
|
||||
<span>
|
||||
Тикет{' '}
|
||||
{ticket_id ? (
|
||||
<a href={`/tickets/${ticket_id}`} onClick={(e) => { e.preventDefault(); navigate(`/tickets/${ticket_id}`); }} style={{ fontWeight: 600 }}>
|
||||
#{ticket_id}
|
||||
</a>
|
||||
) : ('без ID')}{' '}
|
||||
создан
|
||||
</span>
|
||||
),
|
||||
key: ticket_id ? `ticket-${ticket_id}` : `ticket-${Date.now()}`,
|
||||
message: t('ws.newTicket'),
|
||||
description: ticket_id ? `Тикет #${ticket_id} создан` : 'Создан новый тикет',
|
||||
placement: 'topRight',
|
||||
onClick: () => {
|
||||
if (ticket_id) navigate(`/tickets/${ticket_id}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener('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 cutoff = dayjs().subtract(NODE_STATS_THRESHOLD, 'minute');
|
||||
@@ -174,7 +172,7 @@ const AdminLayout: React.FC = () => {
|
||||
<Menu
|
||||
theme="dark"
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
selectedKeys={[getMenuSelectedKey(location.pathname)]}
|
||||
items={filteredMenu}
|
||||
onClick={({ key }) => navigate(key)}
|
||||
style={{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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": "Новый тикет"
|
||||
}
|
||||
}
|
||||
+5
-9
@@ -1,17 +1,13 @@
|
||||
import React from 'react';
|
||||
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';
|
||||
|
||||
dayjs.locale('ru');
|
||||
import './i18n';
|
||||
import LocaleProvider from './components/LocaleProvider';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ConfigProvider locale={locale}>
|
||||
<LocaleProvider>
|
||||
<App />
|
||||
</ConfigProvider>
|
||||
</LocaleProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
|
||||
@@ -5,10 +5,13 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useAdmins, useCreateAdmin, useUpdateAdmin, useDeleteAdmin, useAdmin } from '../../hooks/useAdmins';
|
||||
import { Admin, AdminListParams } from '../../types/api';
|
||||
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 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 createAdmin = useCreateAdmin();
|
||||
const updateAdmin = useUpdateAdmin();
|
||||
@@ -91,13 +94,11 @@ const AdminListPage: React.FC = () => {
|
||||
sorter: SorterResult<Admin> | SorterResult<Admin>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
@@ -207,12 +208,7 @@ const AdminListPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
|
||||
{/* Модальное окно создания */}
|
||||
|
||||
@@ -7,11 +7,14 @@ import { useUser } from '../../hooks/useUsers';
|
||||
import { useEvent } from '../../hooks/useEvents';
|
||||
import { AuditRecord, AuditListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const TABLE_KEY = 'audit';
|
||||
|
||||
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: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
|
||||
@@ -71,13 +74,11 @@ const AuditPage: React.FC = () => {
|
||||
sorter: SorterResult<AuditRecord> | SorterResult<AuditRecord>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const handleDateChange = (dates: any) => {
|
||||
@@ -226,12 +227,7 @@ const AuditPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,35 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Button, Card, message } from 'antd';
|
||||
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 { loginSchema, LoginFormValues } from '../../schemas/loginSchema';
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
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 }) => {
|
||||
console.log('Form submitted', values);
|
||||
const onFinish = async (values: LoginFormValues) => {
|
||||
try {
|
||||
await login(values.email, values.password);
|
||||
navigate('/dashboard');
|
||||
} 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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
|
||||
<Card title="Вход" style={{ width: 400 }}>
|
||||
<Form onFinish={onFinish} layout="vertical">
|
||||
<Form.Item name="email" label="Email" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
<Card title={t('login.title')} style={{ width: 400 }}>
|
||||
<Form layout="vertical" onFinish={handleSubmit(onFinish)}>
|
||||
<Form.Item
|
||||
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 name="password" label="Пароль" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
<Form.Item
|
||||
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>
|
||||
<Button type="primary" htmlType="submit" block>
|
||||
Войти
|
||||
<Button type="primary" htmlType="submit" block loading={isSubmitting}>
|
||||
{t('login.submit')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
@@ -38,4 +70,4 @@ const LoginPage: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
export default LoginPage;
|
||||
|
||||
@@ -8,9 +8,12 @@ import { useAdmin } from '../../hooks/useAdmins';
|
||||
import { BannedWord } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import dayjs from 'dayjs';
|
||||
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
|
||||
|
||||
const TABLE_KEY = 'banned-words';
|
||||
|
||||
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 addWord = useAddBannedWord();
|
||||
const removeWord = useRemoveBannedWord();
|
||||
@@ -31,13 +34,11 @@ const BannedWordsPage: React.FC = () => {
|
||||
sorter: SorterResult<BannedWord> | SorterResult<BannedWord>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
// Обработчик поиска
|
||||
@@ -126,12 +127,7 @@ const BannedWordsPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,13 @@ import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '../../hooks/useCalendars';
|
||||
import { Calendar, CalendarListParams } from '../../types/api';
|
||||
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 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: stats } = useCalendarStats();
|
||||
const updateCalendar = useUpdateCalendar();
|
||||
@@ -101,13 +104,11 @@ const CalendarListPage: React.FC = () => {
|
||||
sorter: SorterResult<Calendar> | SorterResult<Calendar>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current || 1) - 1) * (pagination.pageSize || prev.limit || 20),
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Calendar> = [
|
||||
@@ -204,8 +205,37 @@ const CalendarListPage: React.FC = () => {
|
||||
</Card>
|
||||
</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>
|
||||
)}
|
||||
{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>
|
||||
<Input.Search
|
||||
placeholder="Поиск"
|
||||
@@ -241,12 +271,7 @@ const CalendarListPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
UserOutlined,
|
||||
CalendarOutlined,
|
||||
@@ -8,146 +8,101 @@ import {
|
||||
WarningOutlined,
|
||||
BugOutlined,
|
||||
ClockCircleOutlined,
|
||||
DollarOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useDashboardStats } from '../../hooks/useDashboard';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { AdminActivity } from '../../types/api';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
AreaChart,
|
||||
Area,
|
||||
} from 'recharts';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import DailyLineChartCard from '../../components/DailyLineChartCard';
|
||||
import StatisticSparklineCard from '../../components/StatisticSparklineCard';
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
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 (error) return <Alert type="error" message="Ошибка загрузки статистики" />;
|
||||
if (!data) return null;
|
||||
|
||||
const roleColors: Record<string, string> = {
|
||||
superadmin: 'red',
|
||||
admin: 'blue',
|
||||
moderator: 'purple',
|
||||
support: 'cyan',
|
||||
};
|
||||
if (role === 'moderator') {
|
||||
return (
|
||||
<div>
|
||||
<h2>Модерация</h2>
|
||||
<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> = [
|
||||
{ title: 'Email', dataIndex: 'email', key: 'email' },
|
||||
{ title: 'Ник', dataIndex: 'nickname', key: 'nickname' },
|
||||
{
|
||||
title: 'Роль',
|
||||
dataIndex: 'role',
|
||||
key: 'role',
|
||||
render: (role: string) => (
|
||||
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: 'Действий', dataIndex: 'actions', key: 'actions' },
|
||||
{ title: 'Последний вход', dataIndex: 'last_login', key: 'last_login' },
|
||||
];
|
||||
if (role === 'support') {
|
||||
return (
|
||||
<div>
|
||||
<h2>Поддержка</h2>
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="Назначено открытых" value={data.tickets_assigned_open ?? 0} prefix={<BugOutlined />} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<Card><Statistic title="Всего назначено" value={data.tickets_assigned_total ?? 0} /></Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={8}>
|
||||
<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,
|
||||
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,
|
||||
registrations: item.count,
|
||||
})) || [];
|
||||
value: item.count,
|
||||
})) ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Общая статистика</h2>
|
||||
<Row gutter={[16, 16]}>
|
||||
{/* Пользователи с мини-графиком */}
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Statistic
|
||||
title="Пользователи"
|
||||
value={data.users_total}
|
||||
prefix={<UserOutlined />}
|
||||
style={{ flex: '0 0 auto', marginRight: 16 }}
|
||||
/>
|
||||
{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>
|
||||
<StatisticSparklineCard
|
||||
title="Пользователи"
|
||||
value={data.users_total}
|
||||
prefix={<UserOutlined />}
|
||||
data={registrationsSparkline}
|
||||
color="#52c41a"
|
||||
gradientId="colorRegistrations"
|
||||
/>
|
||||
</Col>
|
||||
|
||||
{/* События с мини-графиком */}
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Statistic
|
||||
title="События"
|
||||
value={data.events_total}
|
||||
prefix={<TeamOutlined />}
|
||||
style={{ flex: '0 0 auto', marginRight: 16 }}
|
||||
/>
|
||||
{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>
|
||||
<StatisticSparklineCard
|
||||
title="События"
|
||||
value={data.events_total}
|
||||
prefix={<TeamOutlined />}
|
||||
data={eventsSparkline}
|
||||
color="#1890ff"
|
||||
gradientId="colorEvents"
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
@@ -190,49 +145,73 @@ const DashboardPage: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</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 gutter={[16, 16]} style={{ marginTop: 32 }}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="События по дням">
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={eventsChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis allowDecimals={false} />
|
||||
<Tooltip />
|
||||
<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>
|
||||
<DailyLineChartCard title="События по дням" data={data.events_by_day} color="#1890ff" />
|
||||
<DailyLineChartCard title="Регистрации по дням" data={data.registrations_by_day} color="#52c41a" />
|
||||
<DailyLineChartCard title="Отзывы по дням" data={data.reviews_by_day} color="#faad14" />
|
||||
<DailyLineChartCard title="Календари по дням" data={data.calendars_by_day} color="#722ed1" />
|
||||
<DailyLineChartCard title="Жалобы по дням" data={data.reports_by_day} color="#ff4d4f" />
|
||||
<DailyLineChartCard title="Тикеты по дням" data={data.tickets_by_day} color="#13c2c2" />
|
||||
<DailyLineChartCard title="Подписки по дням" data={data.subscriptions_by_day} color="#eb2f96" />
|
||||
<DailyLineChartCard title="Неподтверждённые по дням" data={data.pending_users_by_day} color="#fa8c16" />
|
||||
</Row>
|
||||
|
||||
<h2 style={{ marginTop: 32 }}>Активность администраторов</h2>
|
||||
<Table
|
||||
columns={adminColumns}
|
||||
dataSource={data.admin_activity}
|
||||
rowKey="admin_id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
{role === 'superadmin' && (
|
||||
<Alert
|
||||
style={{ marginTop: 32 }}
|
||||
type="info"
|
||||
showIcon
|
||||
message="Активность администраторов"
|
||||
description={<>Подробный журнал действий — в разделе <Link to="/audit">Аудит</Link>.</>}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
export default DashboardPage;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import React from 'react';
|
||||
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 navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const sectionKey = getMenuSelectedKey(location.pathname);
|
||||
const sectionLabel =
|
||||
MENU_ITEMS.find((item) => item.key === sectionKey)?.label ?? 'этот раздел';
|
||||
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
title="403"
|
||||
subTitle="У вас нет доступа к этому разделу."
|
||||
subTitle={`У вас нет доступа к разделу «${sectionLabel}».`}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => navigate('/dashboard')}>
|
||||
На дашборд
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
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 { 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 { Event, EventListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import dayjs from 'dayjs';
|
||||
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
|
||||
|
||||
const TABLE_KEY = 'events';
|
||||
|
||||
const EventListPage: React.FC = () => {
|
||||
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: stats } = useEventStats();
|
||||
const updateEvent = useUpdateEvent();
|
||||
@@ -111,13 +114,11 @@ const EventListPage: React.FC = () => {
|
||||
sorter: SorterResult<Event> | SorterResult<Event>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
@@ -228,20 +229,44 @@ const EventListPage: React.FC = () => {
|
||||
</Card>
|
||||
</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>
|
||||
)}
|
||||
{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
|
||||
columns={columns}
|
||||
dataSource={data?.data}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, Col, Row, Select, Tabs } from 'antd';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import { Card, Col, Row, Select, Table, Tabs } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
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 TIME_RANGES = [
|
||||
@@ -21,43 +13,73 @@ const TIME_RANGES = [
|
||||
{ label: '1 час', value: 60 },
|
||||
];
|
||||
|
||||
const bytesToMb = (value?: number) => (value ?? 0) / 1048576;
|
||||
|
||||
const formatTime = (ts: string) => dayjs(ts).format('HH:mm:ss');
|
||||
|
||||
const buildChartData = (metrics: NodeMetric[]) =>
|
||||
metrics.map((m) => ({
|
||||
time: formatTime(m.timestamp),
|
||||
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,
|
||||
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,
|
||||
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 }> }> = ({
|
||||
data,
|
||||
lines,
|
||||
}) => (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<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 MetricChart: React.FC<{
|
||||
data: ReturnType<typeof buildChartData>;
|
||||
lines: Array<{ key: string; color: string; name: string }>;
|
||||
}> = ({ data, lines }) => (
|
||||
<AreaTrendChart data={data} xKey="time" series={lines} />
|
||||
);
|
||||
|
||||
const MonitoringPage: React.FC = () => {
|
||||
const allHistory = useMetricsStore((s) => s.allHistory);
|
||||
const current = useMetricsStore((s) => s.current);
|
||||
const setAllHistory = useMetricsStore((s) => s.setAllHistory);
|
||||
const filterVisibleHistory = useMetricsStore((s) => s.filterVisibleHistory);
|
||||
const visibleHistory = useMetricsStore((s) => s.visibleHistory);
|
||||
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(() => {
|
||||
filterVisibleHistory(minutes);
|
||||
@@ -83,6 +105,7 @@ const MonitoringPage: React.FC = () => {
|
||||
const tabItems = allNodes.map((node, idx) => {
|
||||
const nodeMetrics = metricsByNode.get(node) || [];
|
||||
const chartData = buildChartData(nodeMetrics);
|
||||
const latestTableSizes = chartData.at(-1)?.tableSizes ?? {};
|
||||
const color = NODE_COLORS[idx % NODE_COLORS.length];
|
||||
|
||||
return {
|
||||
@@ -96,7 +119,21 @@ const MonitoringPage: React.FC = () => {
|
||||
data={chartData}
|
||||
lines={[
|
||||
{ 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>
|
||||
@@ -108,6 +145,8 @@ const MonitoringPage: React.FC = () => {
|
||||
lines={[
|
||||
{ key: 'ws', color: '#52c41a', name: 'WS' },
|
||||
{ key: 'runQueue', color: '#fa8c16', name: 'Run queue' },
|
||||
{ key: 'processCount', color: '#722ed1', name: 'Processes' },
|
||||
{ key: 'activeSessions', color: '#1677ff', name: 'Sessions' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
@@ -123,6 +162,34 @@ const MonitoringPage: React.FC = () => {
|
||||
/>
|
||||
</Card>
|
||||
</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>
|
||||
),
|
||||
};
|
||||
@@ -141,7 +208,9 @@ const MonitoringPage: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{allNodes.length === 0 ? (
|
||||
<Card>Ожидание метрик по WebSocket…</Card>
|
||||
<Card loading={loadingHistory}>
|
||||
{loadingHistory ? 'Загрузка метрик…' : 'Ожидание метрик по WebSocket…'}
|
||||
</Card>
|
||||
) : (
|
||||
<Tabs items={tabItems} />
|
||||
)}
|
||||
|
||||
@@ -2,19 +2,44 @@ import React, { useState } from 'react';
|
||||
import { Card, Descriptions, Spin, Button, Form, Input, Select, Tag, message, Space, Avatar } from 'antd';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useUpdateAdmin } from '../../hooks/useAdmins';
|
||||
import { useUpdateProfile } from '../../hooks/useProfile';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const ProfilePage: React.FC = () => {
|
||||
const { user } = useAuthStore();
|
||||
const updateAdmin = useUpdateAdmin();
|
||||
const updateProfile = useUpdateProfile();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Проверка, является ли текущий пользователь superadmin
|
||||
const isSuperadmin = user?.role === 'superadmin';
|
||||
// Редактирование своего профиля доступно всем ролям (PUT /v1/admin/me)
|
||||
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 = () => {
|
||||
if (!user) return;
|
||||
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
|
||||
@@ -30,32 +55,6 @@ const ProfilePage: React.FC = () => {
|
||||
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) =>
|
||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
|
||||
@@ -72,7 +71,11 @@ const ProfilePage: React.FC = () => {
|
||||
{!editing ? (
|
||||
<>
|
||||
<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>
|
||||
<h3>{!isBadValue(user.nickname) ? user.nickname : user.email}</h3>
|
||||
<Tag
|
||||
@@ -122,11 +125,9 @@ const ProfilePage: React.FC = () => {
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{isSuperadmin && (
|
||||
<Button type="primary" style={{ marginTop: 16 }} onClick={startEditing}>
|
||||
Редактировать
|
||||
</Button>
|
||||
)}
|
||||
<Button type="primary" style={{ marginTop: 16 }} onClick={startEditing}>
|
||||
Редактировать
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
@@ -187,7 +188,7 @@ const ProfilePage: React.FC = () => {
|
||||
<Input.TextArea rows={4} placeholder='{"key": "value"}' />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={updateAdmin.isPending}>
|
||||
<Button type="primary" htmlType="submit" loading={updateProfile.isPending}>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button onClick={() => setEditing(false)}>Отмена</Button>
|
||||
|
||||
@@ -20,7 +20,7 @@ const ReportDetailPage: React.FC = () => {
|
||||
|
||||
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
|
||||
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') => {
|
||||
Modal.confirm({
|
||||
|
||||
@@ -8,10 +8,14 @@ import { useEvent } from '../../hooks/useEvents';
|
||||
import { useAdmin } from '../../hooks/useAdmins';
|
||||
import { Report, ReportListParams } from '../../types/api';
|
||||
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 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: stats } = useReportStats();
|
||||
const updateReport = useUpdateReport();
|
||||
@@ -28,7 +32,7 @@ const ReportListPage: React.FC = () => {
|
||||
|
||||
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
|
||||
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) => {
|
||||
setDetailModal({ open: true, report });
|
||||
@@ -40,13 +44,11 @@ const ReportListPage: React.FC = () => {
|
||||
sorter: SorterResult<Report> | SorterResult<Report>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
// Цвета для типа цели
|
||||
@@ -141,7 +143,7 @@ const ReportListPage: React.FC = () => {
|
||||
sorter: true,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'pending' ? 'orange' : status === 'reviewed' ? 'green' : 'default'}>
|
||||
{status}
|
||||
{formatStatusLabel(status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -219,20 +221,44 @@ const ReportListPage: React.FC = () => {
|
||||
</Card>
|
||||
</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>
|
||||
)}
|
||||
{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
|
||||
columns={columns}
|
||||
dataSource={data?.data}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -7,10 +7,14 @@ import { useUser } from '../../hooks/useUsers';
|
||||
import { useEvent } from '../../hooks/useEvents';
|
||||
import { Review, ReviewListParams } from '../../types/api';
|
||||
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 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: stats } = useReviewStats();
|
||||
const updateReview = useUpdateReview();
|
||||
@@ -97,13 +101,11 @@ const ReviewListPage: React.FC = () => {
|
||||
sorter: SorterResult<Review> | SorterResult<Review>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
@@ -205,7 +207,7 @@ const ReviewListPage: React.FC = () => {
|
||||
sorter: true,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'visible' ? 'green' : status === 'hidden' ? 'orange' : 'red'}>
|
||||
{status}
|
||||
{formatStatusLabel(status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -253,8 +255,37 @@ const ReviewListPage: React.FC = () => {
|
||||
</Card>
|
||||
</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>
|
||||
)}
|
||||
{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 }}>
|
||||
<Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
|
||||
Скрыть выбранные
|
||||
@@ -276,12 +307,7 @@ const ReviewListPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
|
||||
{/* Модальное окно индивидуального редактирования */}
|
||||
|
||||
@@ -7,9 +7,12 @@ import { useUser } from '../../hooks/useUsers';
|
||||
import { Subscription, SubscriptionListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import dayjs from 'dayjs';
|
||||
import { getSavedPageSize, getTablePagination, mergeTablePagination } from '../../utils/tablePagination';
|
||||
|
||||
const TABLE_KEY = 'subscriptions';
|
||||
|
||||
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: stats } = useSubscriptionStats();
|
||||
const updateSubscription = useUpdateSubscription();
|
||||
@@ -77,13 +80,11 @@ const SubscriptionListPage: React.FC = () => {
|
||||
sorter: SorterResult<Subscription> | SorterResult<Subscription>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
@@ -198,8 +199,31 @@ const SubscriptionListPage: React.FC = () => {
|
||||
</Card>
|
||||
</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>
|
||||
)}
|
||||
{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 }}>
|
||||
<Select
|
||||
allowClear
|
||||
@@ -230,12 +254,7 @@ const SubscriptionListPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -6,10 +6,14 @@ import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTick
|
||||
import { useAdmin } from '../../hooks/useAdmins';
|
||||
import { Ticket, TicketListParams } from '../../types/api';
|
||||
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 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 deleteTicket = useDeleteTicket();
|
||||
const { data: stats } = useTicketStats();
|
||||
@@ -33,13 +37,11 @@ const TicketListPage: React.FC = () => {
|
||||
sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Ticket> = [
|
||||
@@ -78,7 +80,7 @@ const TicketListPage: React.FC = () => {
|
||||
status === 'open' ? 'red' :
|
||||
status === 'in_progress' ? 'blue' :
|
||||
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"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,10 +5,14 @@ import { useNavigate } from 'react-router-dom';
|
||||
import { useUsers, useUpdateUser, useDeleteUser, useUser, useUserStats } from '../../hooks/useUsers';
|
||||
import { User, UserListParams } from '../../types/api';
|
||||
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 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: stats } = useUserStats();
|
||||
const updateUser = useUpdateUser();
|
||||
@@ -155,13 +159,11 @@ const UserListPage: React.FC = () => {
|
||||
sorter: SorterResult<User> | SorterResult<User>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
setParams(prev => mergeTablePagination({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current - 1) * pagination.pageSize) || 0,
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
}, pagination, TABLE_KEY));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<User> = [
|
||||
@@ -202,7 +204,7 @@ const UserListPage: React.FC = () => {
|
||||
sorter: true,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'active' ? 'green' : status === 'frozen' ? 'orange' : 'red'}>
|
||||
{status}
|
||||
{formatStatusLabel(status)}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
@@ -267,6 +269,13 @@ const UserListPage: React.FC = () => {
|
||||
</Card>
|
||||
</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>
|
||||
)}
|
||||
<Table
|
||||
@@ -275,12 +284,7 @@ const UserListPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
pagination={getTablePagination(params, data?.total)}
|
||||
/>
|
||||
|
||||
{/* Модальное окно редактирования */}
|
||||
|
||||
@@ -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>;
|
||||
@@ -13,6 +13,7 @@ interface AuthState {
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
setUser: (user: Admin) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set) => ({
|
||||
@@ -69,4 +70,6 @@ export const useAuthStore = create<AuthState>((set) => ({
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setUser: (user) => set({ user }),
|
||||
}));
|
||||
@@ -56,6 +56,10 @@ export const useMetricsStore = create<MetricsState>((set, get) => ({
|
||||
|
||||
addToAllHistory: (metric) =>
|
||||
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);
|
||||
return { allHistory: newAll };
|
||||
}),
|
||||
|
||||
+18
-1
@@ -328,16 +328,33 @@ export interface AuditListParams {
|
||||
// ===================== Dashboard Stats =====================
|
||||
export interface DashboardStats {
|
||||
users_total: number;
|
||||
pending_users_total?: number;
|
||||
events_total: number;
|
||||
reviews_total: number;
|
||||
calendars_total: number;
|
||||
reports_total: number;
|
||||
reports_pending?: number;
|
||||
reports_reviewed?: number;
|
||||
reports_dismissed?: number;
|
||||
tickets_total: number;
|
||||
tickets_open: number;
|
||||
subscriptions_total?: number;
|
||||
trial_subscriptions?: number;
|
||||
avg_ticket_resolution_h: number;
|
||||
admin_activity: AdminActivity[];
|
||||
admin_activity?: AdminActivity[];
|
||||
events_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 {
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user