Compare commits
22 Commits
| 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');
|
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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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[] {
|
): 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
wsLog('[WS] Connected');
|
||||||
|
if (canAccessRoute('/reports', role)) {
|
||||||
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
|
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
|
||||||
|
}
|
||||||
|
if (canAccessRoute('/tickets', role)) {
|
||||||
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
|
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') {
|
||||||
|
if (!canReceiveWsNotification(msg.type, role)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLiveEvent =
|
||||||
|
!msg.timestamp || msg.timestamp >= sessionStartSecondsRef.current;
|
||||||
|
|
||||||
|
if (isLiveEvent) {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('admin-ws-message', { detail: msg })
|
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) {
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
console.warn('[WS] Failed to parse message:', e);
|
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;
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
console.error('[WS] Error:', error);
|
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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
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={{
|
||||||
|
|||||||
@@ -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": "Новый тикет"
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
-8
@@ -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>
|
||||||
);
|
);
|
||||||
@@ -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 +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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' }}>
|
|
||||||
<Statistic
|
|
||||||
title="Пользователи"
|
title="Пользователи"
|
||||||
value={data.users_total}
|
value={data.users_total}
|
||||||
prefix={<UserOutlined />}
|
prefix={<UserOutlined />}
|
||||||
style={{ flex: '0 0 auto', marginRight: 16 }}
|
data={registrationsSparkline}
|
||||||
|
color="#52c41a"
|
||||||
|
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' }}>
|
|
||||||
<Statistic
|
|
||||||
title="События"
|
title="События"
|
||||||
value={data.events_total}
|
value={data.events_total}
|
||||||
prefix={<TeamOutlined />}
|
prefix={<TeamOutlined />}
|
||||||
style={{ flex: '0 0 auto', marginRight: 16 }}
|
data={eventsSparkline}
|
||||||
|
color="#1890ff"
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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')}>
|
||||||
На дашборд
|
На дашборд
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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} />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Модальное окно редактирования */}
|
{/* Модальное окно редактирования */}
|
||||||
|
|||||||
@@ -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>;
|
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 }),
|
||||||
}));
|
}));
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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