Восстановлен потерянный код: календари, мониторинг, refresh, stats на list-страницах.
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import apiClient from './client';
|
||||
import { Calendar, CalendarListParams, CalendarStats, PaginatedResponse } from '../types/api';
|
||||
|
||||
const mapListParams = (params: CalendarListParams) => {
|
||||
const { sort, order, ...rest } = params;
|
||||
return {
|
||||
...rest,
|
||||
...(sort ? { sort_by: sort } : {}),
|
||||
...(order ? { sort_order: order } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
export const calendarsApi = {
|
||||
getCalendars: async (params: CalendarListParams): Promise<PaginatedResponse<Calendar>> => {
|
||||
const { data, headers } = await apiClient.get('/v1/admin/calendars', { params: mapListParams(params) });
|
||||
const total = parseInt(headers['x-total-count'] || '0', 10);
|
||||
return { data, total };
|
||||
},
|
||||
getCalendar: async (id: string): Promise<Calendar> => {
|
||||
const { data } = await apiClient.get(`/v1/admin/calendars/${id}`);
|
||||
return data;
|
||||
},
|
||||
updateCalendar: async (id: string, payload: Partial<Calendar>): Promise<void> => {
|
||||
await apiClient.put(`/v1/admin/calendars/${id}`, payload);
|
||||
},
|
||||
deleteCalendar: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/v1/admin/calendars/${id}`);
|
||||
},
|
||||
getCalendarStats: async (): Promise<CalendarStats> => {
|
||||
const { data } = await apiClient.get('/v1/admin/calendars/stats');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
|
||||
+71
-8
@@ -1,4 +1,4 @@
|
||||
import axios from 'axios';
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../utils/constants';
|
||||
|
||||
const apiClient = axios.create({
|
||||
@@ -8,6 +8,31 @@ const apiClient = axios.create({
|
||||
},
|
||||
});
|
||||
|
||||
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
let isRefreshing = false;
|
||||
let pendingRequests: Array<{
|
||||
resolve: (token: string) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}> = [];
|
||||
|
||||
const processQueue = (error: unknown, token: string | null) => {
|
||||
pendingRequests.forEach(({ resolve, reject }) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else if (token) {
|
||||
resolve(token);
|
||||
}
|
||||
});
|
||||
pendingRequests = [];
|
||||
};
|
||||
|
||||
const redirectToLogin = () => {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
window.location.href = '/login';
|
||||
};
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
if (token) {
|
||||
@@ -18,15 +43,53 @@ apiClient.interceptors.request.use((config) => {
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
// Временно отключаем рефреш и просто редиректим при 401
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
window.location.href = '/login';
|
||||
}
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as RetryConfig | undefined;
|
||||
if (!originalRequest || error.response?.status !== 401 || originalRequest._retry) {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
if (!refreshToken) {
|
||||
redirectToLogin();
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
pendingRequests.push({ resolve, reject });
|
||||
}).then((token) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
return apiClient(originalRequest);
|
||||
});
|
||||
}
|
||||
|
||||
originalRequest._retry = true;
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(
|
||||
`${import.meta.env.VITE_API_BASE_URL || ''}/v1/admin/refresh`,
|
||||
{ refresh_token: refreshToken },
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
const newAccess = data.token as string;
|
||||
const newRefresh = data.refresh_token as string;
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, newAccess);
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, newRefresh);
|
||||
|
||||
processQueue(null, newAccess);
|
||||
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null);
|
||||
redirectToLogin();
|
||||
return Promise.reject(refreshError);
|
||||
} finally {
|
||||
isRefreshing = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@@ -17,4 +17,8 @@ export const eventsApi = {
|
||||
deleteEvent: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/v1/admin/events/${id}`);
|
||||
},
|
||||
getEventStats: async (): Promise<import('../types/api').EventStats> => {
|
||||
const { data } = await apiClient.get('/v1/admin/events/stats');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -14,4 +14,8 @@ export const reportsApi = {
|
||||
updateReport: async (id: string, payload: { status: 'reviewed' | 'dismissed' }): Promise<void> => {
|
||||
await apiClient.put(`/v1/admin/reports/${id}`, payload);
|
||||
},
|
||||
getReportStats: async (): Promise<import('../types/api').ReportStats> => {
|
||||
const { data } = await apiClient.get('/v1/admin/reports/stats');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -18,4 +18,8 @@ export const reviewsApi = {
|
||||
const { data } = await apiClient.patch('/v1/admin/reviews', updates);
|
||||
return data;
|
||||
},
|
||||
getReviewStats: async (): Promise<import('../types/api').ReviewStats> => {
|
||||
const { data } = await apiClient.get('/v1/admin/reviews/stats');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -19,4 +19,8 @@ export const subscriptionsApi = {
|
||||
const { data } = await apiClient.delete(`/v1/admin/subscriptions/${id}`);
|
||||
return data;
|
||||
},
|
||||
getSubscriptionStats: async (): Promise<import('../types/api').SubscriptionStats> => {
|
||||
const { data } = await apiClient.get('/v1/admin/subscriptions/stats');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -17,4 +17,8 @@ export const usersApi = {
|
||||
deleteUser: async (id: string): Promise<void> => {
|
||||
await apiClient.delete(`/v1/admin/users/${id}`);
|
||||
},
|
||||
getUserStats: async (): Promise<import('../types/api').UserStats> => {
|
||||
const { data } = await apiClient.get('/v1/admin/users/stats');
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { Tooltip, Progress } from 'antd';
|
||||
import { NodeMetric } from '../store/metricsStore';
|
||||
|
||||
interface Props {
|
||||
node: string;
|
||||
stats: NodeMetric;
|
||||
prevStats?: NodeMetric | null;
|
||||
}
|
||||
|
||||
const MetricIndicator: React.FC<Props> = ({ node, stats, prevStats }) => {
|
||||
const cpu = stats.cpu_utilization ?? 0;
|
||||
const totalMemory = (stats.memory_total ?? 0) + (stats.memory_available ?? 0);
|
||||
const usedMemory = stats.memory_total ?? 0;
|
||||
const memoryPercent = totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0;
|
||||
|
||||
const prevCpu = prevStats?.cpu_utilization;
|
||||
const prevTotal = (prevStats?.memory_total ?? 0) + (prevStats?.memory_available ?? 0);
|
||||
const prevUsed = prevStats?.memory_total ?? 0;
|
||||
const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null;
|
||||
|
||||
const cpuColor = cpu > 80 ? '#ff4d4f' : '#52c41a';
|
||||
const memColor = memoryPercent > 80 ? '#ff4d4f' : '#1890ff';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div>{node}</div>
|
||||
<div>CPU: {cpu.toFixed(1)}% {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}</div>
|
||||
<div>
|
||||
Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ({memoryPercent.toFixed(1)}%)
|
||||
{prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ position: 'relative', width: 48, height: 48, marginRight: 12 }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={memoryPercent}
|
||||
format={() => ''}
|
||||
size={48}
|
||||
strokeColor={memColor}
|
||||
railColor="#f0f0f0"
|
||||
strokeWidth={6}
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
/>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={cpu}
|
||||
format={() => ''}
|
||||
size={32}
|
||||
strokeColor={cpuColor}
|
||||
railColor="#f0f0f0"
|
||||
strokeWidth={6}
|
||||
style={{ position: 'absolute', top: 8, left: 8 }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: 10, fontWeight: 600 }}>
|
||||
{cpu.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default MetricIndicator;
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { calendarsApi } from '../api/calendarsApi';
|
||||
import { Calendar, CalendarListParams } from '../types/api';
|
||||
import { message } from 'antd';
|
||||
import { normalizeData } from '../utils/normalize';
|
||||
|
||||
export const useCalendars = (params: CalendarListParams) => {
|
||||
return useQuery({
|
||||
queryKey: ['calendars', params],
|
||||
queryFn: async () => {
|
||||
const res = await calendarsApi.getCalendars(params);
|
||||
return { data: normalizeData(res.data), total: res.total };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useCalendar = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ['calendars', id],
|
||||
queryFn: () => calendarsApi.getCalendar(id),
|
||||
enabled: !!id && id !== '-',
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateCalendar = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: string; data: Partial<Calendar> }) =>
|
||||
calendarsApi.updateCalendar(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
||||
message.success('Календарь обновлён');
|
||||
},
|
||||
onError: () => message.error('Ошибка обновления'),
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteCalendar = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => calendarsApi.deleteCalendar(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['calendars'] });
|
||||
message.success('Календарь удалён');
|
||||
},
|
||||
onError: () => message.error('Ошибка удаления'),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCalendarStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['calendar-stats'],
|
||||
queryFn: async () => normalizeData(await calendarsApi.getCalendarStats()),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -46,3 +46,10 @@ export const useDeleteEvent = () => {
|
||||
onError: () => message.error('Ошибка удаления'),
|
||||
});
|
||||
};
|
||||
|
||||
export const useEventStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['event-stats'],
|
||||
queryFn: async () => normalizeData(await eventsApi.getEventStats()),
|
||||
});
|
||||
};
|
||||
@@ -34,3 +34,10 @@ export const useUpdateReport = () => {
|
||||
onError: () => message.error('Ошибка обновления'),
|
||||
});
|
||||
};
|
||||
|
||||
export const useReportStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['report-stats'],
|
||||
queryFn: async () => normalizeData(await reportsApi.getReportStats()),
|
||||
});
|
||||
};
|
||||
@@ -52,3 +52,10 @@ export const useBulkUpdateReviews = () => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useReviewStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['review-stats'],
|
||||
queryFn: async () => normalizeData(await reviewsApi.getReviewStats()),
|
||||
});
|
||||
};
|
||||
@@ -46,3 +46,10 @@ export const useDeleteSubscription = () => {
|
||||
onError: () => message.error('Ошибка удаления'),
|
||||
});
|
||||
};
|
||||
|
||||
export const useSubscriptionStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['subscription-stats'],
|
||||
queryFn: async () => normalizeData(await subscriptionsApi.getSubscriptionStats()),
|
||||
});
|
||||
};
|
||||
@@ -45,3 +45,10 @@ export const useDeleteUser = () => {
|
||||
onError: () => message.error('Ошибка удаления'),
|
||||
});
|
||||
};
|
||||
|
||||
export const useUserStats = () => {
|
||||
return useQuery({
|
||||
queryKey: ['user-stats'],
|
||||
queryFn: async () => normalizeData(await usersApi.getUserStats()),
|
||||
});
|
||||
};
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge, Tooltip, Progress } from 'antd';
|
||||
import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge, Tooltip } from 'antd';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '../store/authStore';
|
||||
import { useAdminWebSocket } from '../hooks/useAdminWebSocket';
|
||||
import { useMetricsStore } from '../store/metricsStore';
|
||||
import MetricIndicator from '../components/MetricIndicator';
|
||||
import {
|
||||
DashboardOutlined,
|
||||
UserOutlined,
|
||||
@@ -146,59 +147,11 @@ const AdminLayout: React.FC = () => {
|
||||
return user?.id ?? '—';
|
||||
};
|
||||
|
||||
const NodeIndicator: React.FC<{ node: string; stats: any; prevStats?: any }> = ({ node, stats, prevStats }) => {
|
||||
const cpu = stats?.cpu_utilization ?? 0;
|
||||
const totalMemory = (stats?.memory_total ?? 0) + (stats?.memory_available ?? 0);
|
||||
const usedMemory = stats?.memory_total ?? 0;
|
||||
const memoryPercent = totalMemory > 0 ? (usedMemory / totalMemory) * 100 : 0;
|
||||
|
||||
const prevCpu = prevStats?.cpu_utilization;
|
||||
const prevTotal = (prevStats?.memory_total ?? 0) + (prevStats?.memory_available ?? 0);
|
||||
const prevUsed = prevStats?.memory_total ?? 0;
|
||||
const prevMemoryPercent = prevTotal > 0 ? (prevUsed / prevTotal) * 100 : null;
|
||||
|
||||
const cpuColor = cpu > 80 ? '#ff4d4f' : '#52c41a';
|
||||
const memColor = memoryPercent > 80 ? '#ff4d4f' : '#1890ff';
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<div>{node}</div>
|
||||
<div>CPU: {cpu.toFixed(1)}% {prevCpu !== undefined ? `(было ${prevCpu.toFixed(1)}%)` : ''}</div>
|
||||
<div>Память: {(usedMemory / 1048576).toFixed(0)} / {(totalMemory / 1048576).toFixed(0)} МБ ({memoryPercent.toFixed(1)}%)
|
||||
{prevMemoryPercent !== null ? ` (было ${prevMemoryPercent.toFixed(1)}%)` : ''}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div style={{ position: 'relative', width: 48, height: 48, marginRight: 12 }}>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={memoryPercent}
|
||||
format={() => ''}
|
||||
size={48}
|
||||
strokeColor={memColor}
|
||||
railColor="#f0f0f0"
|
||||
strokeWidth={6}
|
||||
style={{ position: 'absolute', top: 0, left: 0 }}
|
||||
/>
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={cpu}
|
||||
format={() => ''}
|
||||
size={32}
|
||||
strokeColor={cpuColor}
|
||||
railColor="#f0f0f0"
|
||||
strokeWidth={6}
|
||||
style={{ position: 'absolute', top: 8, left: 8 }}
|
||||
/>
|
||||
<div style={{ position: 'absolute', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', fontSize: 10, fontWeight: 600 }}>
|
||||
{cpu.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
const NodeIndicator: React.FC<{ node: string; stats: NonNullable<typeof current>; prevStats?: typeof current }> = ({
|
||||
node,
|
||||
stats,
|
||||
prevStats,
|
||||
}) => <MetricIndicator node={node} stats={stats} prevStats={prevStats} />;
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: '100vh' }}>
|
||||
@@ -327,6 +280,7 @@ const AdminLayout: React.FC = () => {
|
||||
</Tooltip>
|
||||
{onlineNodes.map(node => {
|
||||
const stats = latestByNode.get(node);
|
||||
if (!stats) return null;
|
||||
const prevStats = previous?.node === node ? previous : undefined;
|
||||
return <NodeIndicator key={node} node={node} stats={stats} prevStats={prevStats} />;
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
|
||||
import { useCalendar } from '../../hooks/useCalendars';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const CalendarDetailPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: calendar, isLoading } = useCalendar(id || '');
|
||||
|
||||
const isBadValue = (val: unknown) =>
|
||||
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
|
||||
|
||||
const displayValue = (val: unknown) => (isBadValue(val) ? '-' : String(val));
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
if (isBadValue(dateStr)) return '-';
|
||||
const d = dayjs(dateStr);
|
||||
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
|
||||
};
|
||||
|
||||
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
|
||||
if (!calendar) return <p>Календарь не найден</p>;
|
||||
|
||||
const tagsStr = Array.isArray(calendar.tags) && calendar.tags.length > 0 ? calendar.tags.join(', ') : '-';
|
||||
|
||||
return (
|
||||
<Card title={`Календарь ${calendar.title || calendar.id}`}>
|
||||
<Descriptions bordered column={1}>
|
||||
<Descriptions.Item label="ID">{calendar.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="Название">{calendar.title}</Descriptions.Item>
|
||||
<Descriptions.Item label="Короткое имя">{displayValue(calendar.short_name)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Описание">{displayValue(calendar.description)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Тип">
|
||||
<Tag color={calendar.type === 'commercial' ? 'gold' : 'blue'}>{calendar.type}</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Статус">
|
||||
<Tag color={calendar.status === 'active' ? 'green' : calendar.status === 'frozen' ? 'orange' : 'red'}>
|
||||
{calendar.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Причина">{displayValue(calendar.reason)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Категория">{displayValue(calendar.category)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Владелец">
|
||||
{!isBadValue(calendar.owner_id) ? (
|
||||
<Link to={`/users/${calendar.owner_id}`}>{calendar.owner_id}</Link>
|
||||
) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Цвет">{displayValue(calendar.color)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Изображение">{displayValue(calendar.image_url)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Подтверждение">{displayValue(calendar.confirmation)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Теги">{tagsStr}</Descriptions.Item>
|
||||
<Descriptions.Item label="Рейтинг">{calendar.rating_avg} ({calendar.rating_count})</Descriptions.Item>
|
||||
<Descriptions.Item label="Настройки">
|
||||
{calendar.settings ? JSON.stringify(calendar.settings) : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Создано">{formatDate(calendar.created_at)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Обновлено">{formatDate(calendar.updated_at)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<Button style={{ marginTop: 16 }} onClick={() => navigate('/calendars')}>Назад к списку</Button>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarDetailPage;
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, Card, Col, Row, Statistic } from 'antd';
|
||||
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useCalendars, useUpdateCalendar, useDeleteCalendar, useCalendar, useCalendarStats } from '../../hooks/useCalendars';
|
||||
import { Calendar, CalendarListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
|
||||
const CalendarListPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useState<CalendarListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' });
|
||||
const { data, isLoading } = useCalendars(params);
|
||||
const { data: stats } = useCalendarStats();
|
||||
const updateCalendar = useUpdateCalendar();
|
||||
const deleteCalendar = useDeleteCalendar();
|
||||
|
||||
const [editModal, setEditModal] = useState<{ open: boolean; calendarId: string | null }>({
|
||||
open: false,
|
||||
calendarId: null,
|
||||
});
|
||||
const { data: editingCalendar, isLoading: loadingCalendar } = useCalendar(editModal.calendarId || '');
|
||||
const [editForm] = Form.useForm();
|
||||
const [editHasErrors, setEditHasErrors] = useState(true);
|
||||
const originalCalendarRef = useRef<Calendar | null>(null);
|
||||
|
||||
const validateEditForm = useCallback(() => {
|
||||
const title = editForm.getFieldValue('title');
|
||||
const status = editForm.getFieldValue('status');
|
||||
setEditHasErrors(!title || !status);
|
||||
}, [editForm]);
|
||||
|
||||
const handleEdit = (id: string) => {
|
||||
setEditModal({ open: true, calendarId: id });
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
editForm.validateFields().then((values) => {
|
||||
if (!editModal.calendarId || !originalCalendarRef.current) return;
|
||||
const original = originalCalendarRef.current;
|
||||
const cleanedValues: Record<string, unknown> = {};
|
||||
|
||||
const allKeys = new Set([...Object.keys(values), ...Object.keys(original)]);
|
||||
allKeys.forEach((key) => {
|
||||
const newVal = values[key];
|
||||
if (newVal === '' || newVal === undefined || newVal === null) {
|
||||
const origVal = (original as unknown as Record<string, unknown>)[key];
|
||||
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
|
||||
cleanedValues[key] = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (newVal === '-') return;
|
||||
cleanedValues[key] = newVal;
|
||||
});
|
||||
|
||||
const payload = Object.fromEntries(
|
||||
Object.entries(cleanedValues).filter(([key, v]) => {
|
||||
const origVal = (original as unknown as Record<string, unknown>)[key];
|
||||
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
|
||||
})
|
||||
);
|
||||
|
||||
updateCalendar.mutate(
|
||||
{ id: editModal.calendarId, data: payload },
|
||||
{ onSuccess: () => setEditModal({ open: false, calendarId: null }) }
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCalendar && editModal.open) {
|
||||
originalCalendarRef.current = editingCalendar;
|
||||
const clean = (val: unknown) => (val === '-' || val === 'undefined' ? undefined : val);
|
||||
setTimeout(() => {
|
||||
editForm.setFieldsValue({
|
||||
title: clean(editingCalendar.title),
|
||||
description: clean(editingCalendar.description),
|
||||
type: clean(editingCalendar.type),
|
||||
status: clean(editingCalendar.status),
|
||||
category: clean(editingCalendar.category),
|
||||
reason: clean(editingCalendar.reason),
|
||||
});
|
||||
validateEditForm();
|
||||
}, 0);
|
||||
}
|
||||
}, [editingCalendar, editModal.open, editForm, validateEditForm]);
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
Modal.confirm({
|
||||
title: 'Удалить календарь?',
|
||||
okText: 'Удалить',
|
||||
okType: 'danger',
|
||||
cancelText: 'Отмена',
|
||||
onOk: () => deleteCalendar.mutate(id),
|
||||
});
|
||||
};
|
||||
|
||||
const handleTableChange = (
|
||||
pagination: { current?: number; pageSize?: number },
|
||||
_filters: unknown,
|
||||
sorter: SorterResult<Calendar> | SorterResult<Calendar>[]
|
||||
) => {
|
||||
const s = Array.isArray(sorter) ? sorter[0] : sorter;
|
||||
setParams(prev => ({
|
||||
...prev,
|
||||
sort: s.field as string,
|
||||
order: s.order === 'ascend' ? 'asc' : s.order === 'descend' ? 'desc' : undefined,
|
||||
offset: ((pagination.current || 1) - 1) * (pagination.pageSize || prev.limit || 20),
|
||||
limit: pagination.pageSize || prev.limit,
|
||||
}));
|
||||
};
|
||||
|
||||
const columns: ColumnsType<Calendar> = [
|
||||
{
|
||||
title: <InfoCircleOutlined />,
|
||||
key: 'detail',
|
||||
width: 48,
|
||||
align: 'center',
|
||||
render: (_, record) => (
|
||||
<Tooltip title="Открыть страницу календаря">
|
||||
<Button
|
||||
icon={<InfoCircleOutlined />}
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => navigate(`/calendars/${record.id}`)}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Название',
|
||||
dataIndex: 'title',
|
||||
key: 'title',
|
||||
ellipsis: true,
|
||||
sorter: true,
|
||||
},
|
||||
{
|
||||
title: 'Тип',
|
||||
dataIndex: 'type',
|
||||
key: 'type',
|
||||
width: 110,
|
||||
sorter: true,
|
||||
render: (type: string) => (
|
||||
<Tag color={type === 'commercial' ? 'gold' : 'blue'}>{type}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Статус',
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
width: 100,
|
||||
sorter: true,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'active' ? 'green' : status === 'frozen' ? 'orange' : 'red'}>
|
||||
{status}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Владелец',
|
||||
dataIndex: 'owner_id',
|
||||
key: 'owner_id',
|
||||
render: (ownerId: string) => (
|
||||
<Link to={`/users/${ownerId}`}>{ownerId}</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Рейтинг',
|
||||
key: 'rating',
|
||||
width: 90,
|
||||
render: (_, record) => `${record.rating_avg} (${record.rating_count})`,
|
||||
},
|
||||
{
|
||||
title: 'Действия',
|
||||
key: 'actions',
|
||||
width: 100,
|
||||
render: (_, record) => (
|
||||
<Space>
|
||||
<Tooltip title="Редактировать">
|
||||
<Button icon={<EditOutlined />} size="small" onClick={() => handleEdit(record.id)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Удалить">
|
||||
<Button icon={<DeleteOutlined />} size="small" danger onClick={() => handleDelete(record.id)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Календари</h2>
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Всего" value={stats.total_calendars} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
{Object.entries(stats.calendars_by_status || {}).map(([status, count]) => (
|
||||
<Col xs={12} sm={6} md={4} key={status}>
|
||||
<Card>
|
||||
<Statistic title={status} value={count} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Input.Search
|
||||
placeholder="Поиск"
|
||||
allowClear
|
||||
onSearch={(q) => setParams(prev => ({ ...prev, q: q || undefined, offset: 0 }))}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Статус"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
onChange={(status) => setParams(prev => ({ ...prev, status, offset: 0 }))}
|
||||
options={[
|
||||
{ value: 'active', label: 'active' },
|
||||
{ value: 'frozen', label: 'frozen' },
|
||||
{ value: 'deleted', label: 'deleted' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Тип"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
onChange={(type) => setParams(prev => ({ ...prev, type, offset: 0 }))}
|
||||
options={[
|
||||
{ value: 'personal', label: 'personal' },
|
||||
{ value: 'commercial', label: 'commercial' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.data}
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
onChange={handleTableChange}
|
||||
pagination={{
|
||||
total: data?.total,
|
||||
current: (params.offset || 0) / (params.limit || 20) + 1,
|
||||
pageSize: params.limit || 20,
|
||||
showSizeChanger: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="Редактировать календарь"
|
||||
open={editModal.open}
|
||||
onCancel={() => setEditModal({ open: false, calendarId: null })}
|
||||
onOk={handleSaveEdit}
|
||||
confirmLoading={updateCalendar.isPending}
|
||||
destroyOnHidden
|
||||
width={640}
|
||||
okButtonProps={{ disabled: editHasErrors }}
|
||||
>
|
||||
{loadingCalendar ? (
|
||||
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
|
||||
) : (
|
||||
<Form form={editForm} layout="vertical" preserve={false} onValuesChange={validateEditForm}>
|
||||
<Form.Item label="Название" name="title" rules={[{ required: true, message: 'Введите название' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Описание" name="description">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Тип" name="type">
|
||||
<Select>
|
||||
<Select.Option value="personal">personal</Select.Option>
|
||||
<Select.Option value="commercial">commercial</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="Статус" name="status" rules={[{ required: true, message: 'Выберите статус' }]}>
|
||||
<Select>
|
||||
<Select.Option value="active">active</Select.Option>
|
||||
<Select.Option value="frozen">frozen</Select.Option>
|
||||
<Select.Option value="deleted">deleted</Select.Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="Категория" name="category">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Причина" name="reason">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarListPage;
|
||||
|
||||
@@ -36,8 +36,7 @@ const EventDetailPage: React.FC = () => {
|
||||
<Link to={`/users/${event.specialist_id}`}>{event.specialist_id}</Link>
|
||||
) : '-';
|
||||
const calendarLink = !isBadValue(event.calendar_id) ? (
|
||||
// Пока нет страницы календаря, просто ID, но можно обернуть в ссылку, если появится
|
||||
<span>{event.calendar_id}</span>
|
||||
<Link to={`/calendars/${event.calendar_id}`}>{event.calendar_id}</Link>
|
||||
) : '-';
|
||||
const masterLink = !isBadValue(event.master_id) ? (
|
||||
<Link to={`/events/${event.master_id}`}>{event.master_id}</Link>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, DatePicker, InputNumber } from 'antd';
|
||||
import { InfoCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, DatePicker, InputNumber, Card, Col, Row, Statistic } from 'antd';
|
||||
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, CalendarOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent } from '../../hooks/useEvents';
|
||||
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent, useEventStats } from '../../hooks/useEvents';
|
||||
import { Event, EventListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -11,6 +11,7 @@ const EventListPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useState<EventListParams>({ limit: 20, offset: 0, sort: 'id', order: 'asc' });
|
||||
const { data, isLoading } = useEvents(params);
|
||||
const { data: stats } = useEventStats();
|
||||
const updateEvent = useUpdateEvent();
|
||||
const deleteEvent = useDeleteEvent();
|
||||
|
||||
@@ -213,6 +214,22 @@ const EventListPage: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>События</h2>
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Всего" value={stats.total_events} prefix={<CalendarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
{Object.entries(stats.events_by_status || {}).map(([status, count]) => (
|
||||
<Col xs={12} sm={6} md={4} key={status}>
|
||||
<Card>
|
||||
<Statistic title={status} value={count} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.data}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Card, Col, Row, Select, Tabs } from 'antd';
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMetricsStore, NodeMetric } from '../../store/metricsStore';
|
||||
|
||||
const NODE_COLORS = ['#1677ff', '#52c41a', '#fa8c16', '#eb2f96', '#722ed1', '#13c2c2'];
|
||||
const TIME_RANGES = [
|
||||
{ label: '5 мин', value: 5 },
|
||||
{ label: '15 мин', value: 15 },
|
||||
{ label: '30 мин', value: 30 },
|
||||
{ label: '1 час', value: 60 },
|
||||
];
|
||||
|
||||
const formatTime = (ts: string) => dayjs(ts).format('HH:mm:ss');
|
||||
|
||||
const buildChartData = (metrics: NodeMetric[]) =>
|
||||
metrics.map((m) => ({
|
||||
time: formatTime(m.timestamp),
|
||||
cpu: m.cpu_utilization ?? 0,
|
||||
memoryMb: (m.memory_total ?? 0) / 1048576,
|
||||
ws: m.ws_connections ?? 0,
|
||||
runQueue: m.run_queue ?? 0,
|
||||
mnesiaCommits: m.mnesia_commits ?? 0,
|
||||
mnesiaFailures: m.mnesia_failures ?? 0,
|
||||
}));
|
||||
|
||||
const MetricChart: React.FC<{ data: ReturnType<typeof buildChartData>; lines: Array<{ key: string; color: string; name: string }> }> = ({
|
||||
data,
|
||||
lines,
|
||||
}) => (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" minTickGap={30} />
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
{lines.map((line) => (
|
||||
<Line key={line.key} type="monotone" dataKey={line.key} stroke={line.color} name={line.name} dot={false} />
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
|
||||
const MonitoringPage: React.FC = () => {
|
||||
const allHistory = useMetricsStore((s) => s.allHistory);
|
||||
const current = useMetricsStore((s) => s.current);
|
||||
const filterVisibleHistory = useMetricsStore((s) => s.filterVisibleHistory);
|
||||
const visibleHistory = useMetricsStore((s) => s.visibleHistory);
|
||||
const [minutes, setMinutes] = useState(15);
|
||||
|
||||
useEffect(() => {
|
||||
filterVisibleHistory(minutes);
|
||||
}, [minutes, allHistory.length, filterVisibleHistory]);
|
||||
|
||||
const allNodes = useMemo(() => {
|
||||
const nodes = new Set<string>();
|
||||
[...allHistory, ...(current ? [current] : [])].forEach((m) => nodes.add(m.node));
|
||||
return Array.from(nodes).sort();
|
||||
}, [allHistory, current]);
|
||||
|
||||
const metricsByNode = useMemo(() => {
|
||||
const source = visibleHistory.length > 0 ? visibleHistory : allHistory;
|
||||
const map = new Map<string, NodeMetric[]>();
|
||||
source.forEach((m) => {
|
||||
const list = map.get(m.node) || [];
|
||||
list.push(m);
|
||||
map.set(m.node, list);
|
||||
});
|
||||
return map;
|
||||
}, [visibleHistory, allHistory]);
|
||||
|
||||
const tabItems = allNodes.map((node, idx) => {
|
||||
const nodeMetrics = metricsByNode.get(node) || [];
|
||||
const chartData = buildChartData(nodeMetrics);
|
||||
const color = NODE_COLORS[idx % NODE_COLORS.length];
|
||||
|
||||
return {
|
||||
key: node,
|
||||
label: <span style={{ color }}>{node}</span>,
|
||||
children: (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col span={24}>
|
||||
<Card title="CPU и память (МБ)">
|
||||
<MetricChart
|
||||
data={chartData}
|
||||
lines={[
|
||||
{ key: 'cpu', color, name: 'CPU %' },
|
||||
{ key: 'memoryMb', color: '#1890ff', name: 'Память МБ' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="WebSocket и очередь">
|
||||
<MetricChart
|
||||
data={chartData}
|
||||
lines={[
|
||||
{ key: 'ws', color: '#52c41a', name: 'WS' },
|
||||
{ key: 'runQueue', color: '#fa8c16', name: 'Run queue' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card title="Mnesia">
|
||||
<MetricChart
|
||||
data={chartData}
|
||||
lines={[
|
||||
{ key: 'mnesiaCommits', color: '#722ed1', name: 'Commits' },
|
||||
{ key: 'mnesiaFailures', color: '#ff4d4f', name: 'Failures' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||
<h2 style={{ margin: 0 }}>Мониторинг нод</h2>
|
||||
<Select
|
||||
value={minutes}
|
||||
onChange={setMinutes}
|
||||
options={TIME_RANGES}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{allNodes.length === 0 ? (
|
||||
<Card>Ожидание метрик по WebSocket…</Card>
|
||||
) : (
|
||||
<Tabs items={tabItems} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MonitoringPage;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Table, Button, Tag, Modal, Descriptions, Tooltip, Spin, Space } from 'antd';
|
||||
import { InfoCircleOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { Table, Button, Tag, Modal, Descriptions, Tooltip, Spin, Space, Card, Col, Row, Statistic } from 'antd';
|
||||
import { InfoCircleOutlined, EyeOutlined, WarningOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useReports, useUpdateReport } from '../../hooks/useReports';
|
||||
import { useReports, useUpdateReport, useReportStats } from '../../hooks/useReports';
|
||||
import { useUser } from '../../hooks/useUsers';
|
||||
import { useEvent } from '../../hooks/useEvents';
|
||||
import { useAdmin } from '../../hooks/useAdmins';
|
||||
@@ -13,6 +13,7 @@ const ReportListPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useState<ReportListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' });
|
||||
const { data, isLoading } = useReports(params);
|
||||
const { data: stats } = useReportStats();
|
||||
const updateReport = useUpdateReport();
|
||||
|
||||
const [detailModal, setDetailModal] = useState<{ open: boolean; report: Report | null }>({
|
||||
@@ -204,6 +205,22 @@ const ReportListPage: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Жалобы</h2>
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Всего" value={stats.total_reports} prefix={<WarningOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
{Object.entries(stats.reports_by_status || {}).map(([status, count]) => (
|
||||
<Col xs={12} sm={6} md={4} key={status}>
|
||||
<Card>
|
||||
<Statistic title={status} value={count} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.data}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, InputNumber, message, Tooltip, Input, Spin } from 'antd';
|
||||
import { InfoCircleOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, InputNumber, message, Tooltip, Input, Spin, Card, Col, Row, Statistic } from 'antd';
|
||||
import { InfoCircleOutlined, EditOutlined, StarOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useReviews, useUpdateReview, useBulkUpdateReviews, useReview } from '../../hooks/useReviews';
|
||||
import { useReviews, useUpdateReview, useBulkUpdateReviews, useReview, useReviewStats } from '../../hooks/useReviews';
|
||||
import { useUser } from '../../hooks/useUsers';
|
||||
import { useEvent } from '../../hooks/useEvents';
|
||||
import { Review, ReviewListParams } from '../../types/api';
|
||||
@@ -12,6 +12,7 @@ const ReviewListPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useState<ReviewListParams>({ limit: 20, offset: 0, sort: 'created_at', order: 'desc' });
|
||||
const { data, isLoading } = useReviews(params);
|
||||
const { data: stats } = useReviewStats();
|
||||
const updateReview = useUpdateReview();
|
||||
const bulkUpdate = useBulkUpdateReviews();
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
|
||||
@@ -238,6 +239,22 @@ const ReviewListPage: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Отзывы</h2>
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Всего" value={stats.total_reviews} prefix={<StarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
{Object.entries(stats.reviews_by_status || {}).map(([status, count]) => (
|
||||
<Col xs={12} sm={6} md={4} key={status}>
|
||||
<Card>
|
||||
<Statistic title={status} value={count} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
|
||||
Скрыть выбранные
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, DatePicker, Tooltip, Spin } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, DatePicker, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
|
||||
import { EditOutlined, DeleteOutlined, DollarOutlined } from '@ant-design/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useSubscriptions, useUpdateSubscription, useDeleteSubscription, useSubscription } from '../../hooks/useSubscriptions';
|
||||
import { useSubscriptions, useUpdateSubscription, useDeleteSubscription, useSubscription, useSubscriptionStats } from '../../hooks/useSubscriptions';
|
||||
import { useUser } from '../../hooks/useUsers';
|
||||
import { Subscription, SubscriptionListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
@@ -11,6 +11,7 @@ import dayjs from 'dayjs';
|
||||
const SubscriptionListPage: React.FC = () => {
|
||||
const [params, setParams] = useState<SubscriptionListParams>({ limit: 20, offset: 0 });
|
||||
const { data, isLoading } = useSubscriptions(params);
|
||||
const { data: stats } = useSubscriptionStats();
|
||||
const updateSubscription = useUpdateSubscription();
|
||||
const deleteSubscription = useDeleteSubscription();
|
||||
|
||||
@@ -178,6 +179,27 @@ const SubscriptionListPage: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Подписки</h2>
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Всего" value={stats.total_subscriptions} prefix={<DollarOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Пробные" value={stats.trial_subscriptions} />
|
||||
</Card>
|
||||
</Col>
|
||||
{Object.entries(stats.subscriptions_by_status || {}).map(([status, count]) => (
|
||||
<Col xs={12} sm={6} md={4} key={status}>
|
||||
<Card>
|
||||
<Statistic title={status} value={count} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Select
|
||||
allowClear
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip } from 'antd';
|
||||
import { InfoCircleOutlined, EditOutlined, LockOutlined, UnlockOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import { Table, Button, Tag, Space, Modal, Form, Select, Spin, Input, Tooltip, Card, Col, Row, Statistic } from 'antd';
|
||||
import { InfoCircleOutlined, EditOutlined, LockOutlined, UnlockOutlined, DeleteOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useUsers, useUpdateUser, useDeleteUser, useUser } from '../../hooks/useUsers';
|
||||
import { useUsers, useUpdateUser, useDeleteUser, useUser, useUserStats } from '../../hooks/useUsers';
|
||||
import { User, UserListParams } from '../../types/api';
|
||||
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
|
||||
|
||||
@@ -10,6 +10,7 @@ const UserListPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [params, setParams] = useState<UserListParams>({ limit: 20, offset: 0, sort: 'id', order: 'asc' });
|
||||
const { data, isLoading } = useUsers(params);
|
||||
const { data: stats } = useUserStats();
|
||||
const updateUser = useUpdateUser();
|
||||
const deleteUser = useDeleteUser();
|
||||
|
||||
@@ -247,6 +248,27 @@ const UserListPage: React.FC = () => {
|
||||
return (
|
||||
<div>
|
||||
<h2>Пользователи</h2>
|
||||
{stats && (
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Всего" value={stats.total_users} prefix={<UserOutlined />} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Card>
|
||||
<Statistic title="Неподтверждённые" value={stats.pending_users} />
|
||||
</Card>
|
||||
</Col>
|
||||
{Object.entries(stats.users_by_status || {}).map(([status, count]) => (
|
||||
<Col xs={12} sm={6} md={4} key={status}>
|
||||
<Card>
|
||||
<Statistic title={status} value={count} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={data?.data}
|
||||
|
||||
@@ -30,6 +30,66 @@ export interface CalendarListParams {
|
||||
order?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface CalendarRankItem {
|
||||
id: string;
|
||||
title: string;
|
||||
rating_avg: number;
|
||||
review_count: number;
|
||||
}
|
||||
|
||||
export interface CalendarStats {
|
||||
total_calendars: number;
|
||||
calendars_by_type: Record<string, number>;
|
||||
calendars_by_status: Record<string, number>;
|
||||
top_calendars_by_reviews: CalendarRankItem[];
|
||||
top_calendars_by_positive_reviews: CalendarRankItem[];
|
||||
top_calendars_by_negative_reviews: CalendarRankItem[];
|
||||
top_calendars_by_rating: CalendarRankItem[];
|
||||
}
|
||||
|
||||
export interface EventStats {
|
||||
total_events: number;
|
||||
events_by_type: Record<string, number>;
|
||||
events_by_status: Record<string, number>;
|
||||
top_events_by_rating: CalendarRankItem[];
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
total_users: number;
|
||||
users_by_role: Record<string, number>;
|
||||
users_by_status: Record<string, number>;
|
||||
pending_users: number;
|
||||
}
|
||||
|
||||
export interface ReportStats {
|
||||
total_reports: number;
|
||||
reports_by_target_type: Record<string, number>;
|
||||
reports_by_status: Record<string, number>;
|
||||
top_targets_by_reports: Array<{ target_type: string; target_id: string; report_count: number }>;
|
||||
}
|
||||
|
||||
export interface ReviewStats {
|
||||
total_reviews: number;
|
||||
reviews_by_target_type: Record<string, number>;
|
||||
reviews_by_status: Record<string, number>;
|
||||
top_targets_by_reviews: Array<{ target_type: string; target_id: string; review_count: number }>;
|
||||
top_targets_by_positive_reviews: Array<{ target_type: string; target_id: string; review_count: number }>;
|
||||
top_targets_by_negative_reviews: Array<{ target_type: string; target_id: string; review_count: number }>;
|
||||
}
|
||||
|
||||
export interface SubscriptionStats {
|
||||
total_subscriptions: number;
|
||||
subscriptions_by_plan: Record<string, number>;
|
||||
subscriptions_by_status: Record<string, number>;
|
||||
trial_subscriptions: number;
|
||||
ending_paid_subscriptions: Array<{
|
||||
id: string;
|
||||
user_id: string;
|
||||
plan: string;
|
||||
expires_at: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ===================== Event (Событие) =====================
|
||||
export interface Event {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user