Разработка админ-панели EventHubFrontAdmin v1.0 #1

This commit is contained in:
2026-05-23 20:43:21 +03:00
parent 36c95b71a4
commit f5961c5529
68 changed files with 10374 additions and 3 deletions
+184
View File
@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
+77
View File
@@ -0,0 +1,77 @@
import React, { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Spin } from 'antd';
import ProtectedRoute from './components/ProtectedRoute';
import AdminLayout from './layouts/AdminLayout';
import LoginPage from './pages/auth/LoginPage';
import ProfilePage from './pages/profile/ProfilePage';
import DashboardPage from './pages/dashboard/DashboardPage';
import UserListPage from './pages/users/UserListPage';
import UserDetailPage from './pages/users/UserDetailPage';
import EventListPage from './pages/events/EventListPage';
import EventDetailPage from './pages/events/EventDetailPage';
import ReportListPage from './pages/reports/ReportListPage';
import ReportDetailPage from './pages/reports/ReportDetailPage';
import ReviewListPage from './pages/reviews/ReviewListPage';
import ReviewDetailPage from './pages/reviews/ReviewDetailPage';
import BannedWordsPage from './pages/banned-words/BannedWordsPage';
import TicketListPage from './pages/tickets/TicketListPage';
import TicketDetailPage from './pages/tickets/TicketDetailPage';
import SubscriptionListPage from './pages/subscriptions/SubscriptionListPage';
import AdminListPage from './pages/admins/AdminListPage';
import AdminDetailPage from './pages/admins/AdminDetailPage';
import AuditPage from './pages/audit/AuditPage';
import { useAuthStore } from './store/authStore';
const queryClient = new QueryClient();
const App: React.FC = () => {
const { isInitialized, checkAuth } = useAuthStore();
useEffect(() => {
checkAuth();
}, []);
if (!isInitialized) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route element={<ProtectedRoute />}>
<Route element={<AdminLayout />}>
<Route path="/" element={<Navigate to="/dashboard" />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/users" element={<UserListPage />} />
<Route path="/users/:id" element={<UserDetailPage />} />
<Route path="/events" element={<EventListPage />} />
<Route path="/events/:id" element={<EventDetailPage />} />
<Route path="/reports" element={<ReportListPage />} />
<Route path="/reports/:id" element={<ReportDetailPage />} />
<Route path="/reviews" element={<ReviewListPage />} />
<Route path="/reviews/:id" element={<ReviewDetailPage />} />
<Route path="/banned-words" element={<BannedWordsPage />} />
<Route path="/tickets" element={<TicketListPage />} />
<Route path="/tickets/:id" element={<TicketDetailPage />} />
<Route path="/subscriptions" element={<SubscriptionListPage />} />
<Route path="/admins" element={<AdminListPage />} />
<Route path="/admins/:id" element={<AdminDetailPage />} />
<Route path="/audit" element={<AuditPage />} />
</Route>
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
};
export default App;
+25
View File
@@ -0,0 +1,25 @@
import apiClient from './client';
import { Admin, AdminListParams, PaginatedResponse } from '../types/api';
export const adminsApi = {
getAdmins: async (params: AdminListParams): Promise<PaginatedResponse<Admin>> => {
const { data, headers } = await apiClient.get('/v1/admin/admins', { params });
const total = parseInt(headers['x-total-count'] || '0', 10);
return { data, total };
},
getAdmin: async (id: string): Promise<Admin> => {
const { data } = await apiClient.get(`/v1/admin/admins/${id}`);
return data;
},
createAdmin: async (payload: { email: string; password: string; role: string }): Promise<Admin> => {
const { data } = await apiClient.post('/v1/admin/admins', payload);
return data;
},
updateAdmin: async (id: string, payload: Partial<Admin>): Promise<Admin> => {
const { data } = await apiClient.put(`/v1/admin/admins/${id}`, payload);
return data;
},
deleteAdmin: async (id: string): Promise<void> => {
await apiClient.delete(`/v1/admin/admins/${id}`);
},
};
+10
View File
@@ -0,0 +1,10 @@
import apiClient from './client';
import { AuditRecord, AuditListParams, PaginatedResponse } from '../types/api';
export const auditApi = {
getAuditRecords: async (params: AuditListParams): Promise<PaginatedResponse<AuditRecord>> => {
const { data, headers } = await apiClient.get('/v1/admin/audit', { params });
const total = parseInt(headers['x-total-count'] || '0', 10);
return { data, total };
},
};
+19
View File
@@ -0,0 +1,19 @@
import apiClient from './client';
import { Admin } from '../types/api';
interface LoginResponse {
token: string;
refresh_token: string;
user: Admin;
}
export const authApi = {
login: async (email: string, password: string): Promise<LoginResponse> => {
const { data } = await apiClient.post('/v1/admin/login', { email, password });
return data;
},
getMe: async (): Promise<Admin> => {
const { data } = await apiClient.get('/v1/admin/me');
return data;
},
};
+24
View File
@@ -0,0 +1,24 @@
import apiClient from './client';
import { BannedWord, PaginatedResponse } from '../types/api';
export interface BannedWordListParams {
q?: string;
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
export const bannedWordsApi = {
getBannedWords: async (params?: BannedWordListParams): Promise<PaginatedResponse<BannedWord>> => {
const { data, headers } = await apiClient.get('/v1/admin/banned-words', { params });
const total = parseInt(headers['x-total-count'] || '0', 10);
return { data, total };
},
addBannedWord: async (word: string): Promise<void> => {
await apiClient.post('/v1/admin/banned-words', { word });
},
removeBannedWord: async (word: string): Promise<void> => {
await apiClient.delete(`/v1/admin/banned-words/${encodeURIComponent(word)}`);
},
};
+32
View File
@@ -0,0 +1,32 @@
import axios from 'axios';
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../utils/constants';
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_BASE_URL || '',
headers: {
'Content-Type': 'application/json',
},
});
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return 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';
}
return Promise.reject(error);
}
);
export default apiClient;
+12
View File
@@ -0,0 +1,12 @@
import apiClient from './client';
import { DashboardStats } from '../types/api';
export const dashboardApi = {
getStats: async (from?: string, to?: string): Promise<DashboardStats> => {
const params: Record<string, string> = {};
if (from) params.from = from;
if (to) params.to = to;
const { data } = await apiClient.get('/v1/admin/stats', { params });
return data;
},
};
+20
View File
@@ -0,0 +1,20 @@
import apiClient from './client';
import { Event, EventListParams, PaginatedResponse } from '../types/api';
export const eventsApi = {
getEvents: async (params: EventListParams): Promise<PaginatedResponse<Event>> => {
const { data, headers } = await apiClient.get('/v1/admin/events', { params });
const total = parseInt(headers['x-total-count'] || '0', 10);
return { data, total };
},
getEvent: async (id: string): Promise<Event> => {
const { data } = await apiClient.get(`/v1/admin/events/${id}`);
return data;
},
updateEvent: async (id: string, payload: Partial<Event>): Promise<void> => {
await apiClient.put(`/v1/admin/events/${id}`, payload);
},
deleteEvent: async (id: string): Promise<void> => {
await apiClient.delete(`/v1/admin/events/${id}`);
},
};
+17
View File
@@ -0,0 +1,17 @@
import apiClient from './client';
import { Report, ReportListParams, PaginatedResponse } from '../types/api';
export const reportsApi = {
getReports: async (params: ReportListParams): Promise<PaginatedResponse<Report>> => {
const { data, headers } = await apiClient.get('/v1/admin/reports', { params });
const total = parseInt(headers['x-total-count'] || '0', 10);
return { data, total };
},
getReport: async (id: string): Promise<Report> => {
const { data } = await apiClient.get(`/v1/admin/reports/${id}`);
return data;
},
updateReport: async (id: string, payload: { status: 'reviewed' | 'dismissed' }): Promise<void> => {
await apiClient.put(`/v1/admin/reports/${id}`, payload);
},
};
+21
View File
@@ -0,0 +1,21 @@
import apiClient from './client';
import { Review, ReviewListParams, PaginatedResponse } from '../types/api';
export const reviewsApi = {
getReviews: async (params: ReviewListParams): Promise<PaginatedResponse<Review>> => {
const { data, headers } = await apiClient.get('/v1/admin/reviews', { params });
const total = parseInt(headers['x-total-count'] || '0', 10);
return { data, total };
},
getReview: async (id: string): Promise<Review> => {
const { data } = await apiClient.get(`/v1/admin/reviews/${id}`);
return data;
},
updateReview: async (id: string, payload: Partial<Review>): Promise<void> => {
await apiClient.put(`/v1/admin/reviews/${id}`, payload);
},
bulkUpdateReviews: async (updates: { id: string; status: string }[]): Promise<number> => {
const { data } = await apiClient.patch('/v1/admin/reviews', updates);
return data;
},
};
+22
View File
@@ -0,0 +1,22 @@
import apiClient from './client';
import { Subscription, SubscriptionListParams, PaginatedResponse } from '../types/api';
export const subscriptionsApi = {
getSubscriptions: async (params: SubscriptionListParams): Promise<PaginatedResponse<Subscription>> => {
const { data, headers } = await apiClient.get('/v1/admin/subscriptions', { params });
const total = parseInt(headers['x-total-count'] || '0', 10);
return { data, total };
},
getSubscription: async (id: string): Promise<Subscription> => {
const { data } = await apiClient.get(`/v1/admin/subscriptions/${id}`);
return data;
},
updateSubscription: async (id: string, payload: Partial<Subscription>): Promise<Subscription> => {
const { data } = await apiClient.put(`/v1/admin/subscriptions/${id}`, payload);
return data;
},
deleteSubscription: async (id: string): Promise<Subscription> => {
const { data } = await apiClient.delete(`/v1/admin/subscriptions/${id}`);
return data;
},
};
+24
View File
@@ -0,0 +1,24 @@
import apiClient from './client';
import { Ticket, TicketListParams, TicketStats, PaginatedResponse } from '../types/api';
export const ticketsApi = {
getTickets: async (params: TicketListParams): Promise<PaginatedResponse<Ticket>> => {
const { data, headers } = await apiClient.get('/v1/admin/tickets', { params });
const total = parseInt(headers['x-total-count'] || '0', 10);
return { data, total };
},
getTicket: async (id: string): Promise<Ticket> => {
const { data } = await apiClient.get(`/v1/admin/tickets/${id}`);
return data;
},
updateTicket: async (id: string, payload: Partial<Ticket>): Promise<void> => {
await apiClient.put(`/v1/admin/tickets/${id}`, payload);
},
deleteTicket: async (id: string): Promise<void> => {
await apiClient.delete(`/v1/admin/tickets/${id}`);
},
getTicketStats: async (): Promise<TicketStats> => {
const { data } = await apiClient.get('/v1/admin/tickets/stats');
return data;
},
};
+20
View File
@@ -0,0 +1,20 @@
import apiClient from './client';
import { User, UserListParams, PaginatedResponse } from '../types/api';
export const usersApi = {
getUsers: async (params: UserListParams): Promise<PaginatedResponse<User>> => {
const { data, headers } = await apiClient.get('/v1/admin/users', { params });
const total = parseInt(headers['x-total-count'] || '0', 10);
return { data, total };
},
getUser: async (id: string): Promise<User> => {
const { data } = await apiClient.get(`/v1/admin/users/${id}`);
return data;
},
updateUser: async (id: string, payload: Partial<User>): Promise<void> => {
await apiClient.put(`/v1/admin/users/${id}`, payload);
},
deleteUser: async (id: string): Promise<void> => {
await apiClient.delete(`/v1/admin/users/${id}`);
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+32
View File
@@ -0,0 +1,32 @@
import React from 'react';
import { Navigate, Outlet } from 'react-router-dom';
import { Spin } from 'antd';
import { useAuthStore } from '../store/authStore';
interface Props {
allowedRoles?: string[];
}
const ProtectedRoute: React.FC<Props> = ({ allowedRoles }) => {
const { isAuthenticated, isInitialized, user } = useAuthStore();
if (!isInitialized) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (allowedRoles && user && !allowedRoles.includes(user.role)) {
return <Navigate to="/dashboard" replace />;
}
return <Outlet />;
};
export default ProtectedRoute;
+101
View File
@@ -0,0 +1,101 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '../store/authStore';
type WsMessage = {
type: 'report_created' | 'ticket_created';
data?: {
report_id?: string;
ticket_id?: string;
target_type?: string;
target_id?: string;
reason?: string;
};
status?: string;
channel?: string;
timestamp?: number;
};
export const useAdminWebSocket = () => {
const queryClient = useQueryClient();
const accessToken = useAuthStore((s) => s.accessToken);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const pingIntervalRef = useRef<ReturnType<typeof setInterval>>();
const lastMessageTimestamp = useRef<number>(0);
const wsRef = useRef<WebSocket | null>(null);
useEffect(() => {
if (!isAuthenticated || !accessToken) return;
let isMounted = true;
const connect = () => {
const wsUrl = import.meta.env.DEV
? `ws://localhost:5173/admin/ws?token=${accessToken}`
: `${import.meta.env.VITE_WS_URL || 'wss://admin-ws.eventhub.local'}/admin/ws?token=${accessToken}`;
const ws = new WebSocket(wsUrl);
wsRef.current = ws;
ws.onopen = () => {
if (!isMounted) return;
console.log('[WS] Connected');
ws.send(JSON.stringify({ action: 'subscribe', channel: 'reports' }));
ws.send(JSON.stringify({ action: 'subscribe', channel: 'tickets' }));
pingIntervalRef.current = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ action: 'ping' }));
}
}, 30_000);
};
ws.onmessage = (event) => {
if (!isMounted) return;
try {
const msg: WsMessage = JSON.parse(event.data);
console.log('[WS] Message:', msg);
if (msg.timestamp && msg.timestamp === lastMessageTimestamp.current) {
return;
}
if (msg.timestamp) {
lastMessageTimestamp.current = msg.timestamp;
}
if (msg.type === 'report_created' || msg.type === 'ticket_created') {
window.dispatchEvent(
new CustomEvent('admin-ws-message', { detail: msg })
);
if (msg.type === 'report_created') {
queryClient.invalidateQueries({ queryKey: ['reports'] });
} else if (msg.type === 'ticket_created') {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
queryClient.invalidateQueries({ queryKey: ['ticket-stats'] });
}
}
} catch (e) {
console.warn('[WS] Failed to parse message:', e);
}
};
ws.onclose = () => {
if (!isMounted) return;
console.log('[WS] Disconnected, will reconnect in 5s');
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
reconnectTimeoutRef.current = setTimeout(connect, 5000);
};
// Не вызываем ws.close() при ошибке, браузер закроет сокет сам
};
connect();
return () => {
isMounted = false;
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
if (pingIntervalRef.current) clearInterval(pingIntervalRef.current);
// Никакого принудительного закрытия wsRef.current – браузер сам управится
};
}, [isAuthenticated, accessToken, queryClient]);
};
+62
View File
@@ -0,0 +1,62 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminsApi } from '../api/adminsApi';
import { AdminListParams } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
export const useAdmins = (params: AdminListParams) => {
return useQuery({
queryKey: ['admins', params],
queryFn: async () => {
const res = await adminsApi.getAdmins(params);
return { data: normalizeData(res.data), total: res.total };
},
});
};
export const useAdmin = (id: string) => {
return useQuery({
queryKey: ['admins', id],
queryFn: () => adminsApi.getAdmin(id),
enabled: !!id && id !== '-' && id !== 'undefined',
retry: false,
});
};
export const useCreateAdmin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: { email: string; password: string; role: string }) =>
adminsApi.createAdmin(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] });
message.success('Администратор создан');
},
onError: () => message.error('Ошибка создания'),
});
};
export const useUpdateAdmin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Admin> }) =>
adminsApi.updateAdmin(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] });
message.success('Администратор обновлён');
},
onError: () => message.error('Ошибка обновления'),
});
};
export const useDeleteAdmin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => adminsApi.deleteAdmin(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admins'] });
message.success('Администратор удалён');
},
onError: () => message.error('Ошибка удаления'),
});
};
+14
View File
@@ -0,0 +1,14 @@
import { useQuery } from '@tanstack/react-query';
import { auditApi } from '../api/auditApi';
import { AuditListParams } from '../types/api';
import { normalizeData } from '../utils/normalize';
export const useAudit = (params: AuditListParams) => {
return useQuery({
queryKey: ['audit', params],
queryFn: async () => {
const res = await auditApi.getAuditRecords(params);
return { data: normalizeData(res.data), total: res.total };
},
});
};
+38
View File
@@ -0,0 +1,38 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { bannedWordsApi, BannedWordListParams } from '../api/bannedWordsApi';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
export const useBannedWords = (params?: BannedWordListParams) => {
return useQuery({
queryKey: ['banned-words', params],
queryFn: async () => {
const res = await bannedWordsApi.getBannedWords(params);
return { data: normalizeData(res.data), total: res.total };
},
});
};
export const useAddBannedWord = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (word: string) => bannedWordsApi.addBannedWord(word),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['banned-words'] });
message.success('Слово добавлено');
},
onError: () => message.error('Ошибка добавления'),
});
};
export const useRemoveBannedWord = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (word: string) => bannedWordsApi.removeBannedWord(word),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['banned-words'] });
message.success('Слово удалено');
},
onError: () => message.error('Ошибка удаления'),
});
};
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from '@tanstack/react-query';
import { dashboardApi } from '../api/dashboardApi';
import { normalizeData } from '../utils/normalize';
export const useDashboardStats = (from?: string, to?: string) => {
return useQuery({
queryKey: ['dashboard', from, to],
queryFn: async () => {
const data = await dashboardApi.getStats(from, to);
return normalizeData(data);
},
});
};
+48
View File
@@ -0,0 +1,48 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsApi } from '../api/eventsApi';
import { EventListParams } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
export const useEvents = (params: EventListParams) => {
return useQuery({
queryKey: ['events', params],
queryFn: async () => {
const res = await eventsApi.getEvents(params);
return { data: normalizeData(res.data), total: res.total };
},
});
};
export const useEvent = (id: string) => {
return useQuery({
queryKey: ['events', id],
queryFn: () => eventsApi.getEvent(id), // сырые данные для формы
enabled: !!id && id !== '-',
});
};
export const useUpdateEvent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Event> }) =>
eventsApi.updateEvent(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
message.success('Событие обновлено');
},
onError: () => message.error('Ошибка обновления'),
});
};
export const useDeleteEvent = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => eventsApi.deleteEvent(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['events'] });
message.success('Событие удалено');
},
onError: () => message.error('Ошибка удаления'),
});
};
+36
View File
@@ -0,0 +1,36 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { reportsApi } from '../api/reportsApi';
import { ReportListParams } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
export const useReports = (params: ReportListParams) => {
return useQuery({
queryKey: ['reports', params],
queryFn: async () => {
const res = await reportsApi.getReports(params);
return { data: normalizeData(res.data), total: res.total };
},
});
};
export const useReport = (id: string) => {
return useQuery({
queryKey: ['reports', id],
queryFn: () => reportsApi.getReport(id),
enabled: !!id,
});
};
export const useUpdateReport = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: { status: 'reviewed' | 'dismissed' } }) =>
reportsApi.updateReport(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reports'] });
message.success('Статус обновлён');
},
onError: () => message.error('Ошибка обновления'),
});
};
+54
View File
@@ -0,0 +1,54 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { reviewsApi } from '../api/reviewsApi';
import { ReviewListParams } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
export const useReviews = (params: ReviewListParams) => {
return useQuery({
queryKey: ['reviews', params],
queryFn: async () => {
const res = await reviewsApi.getReviews(params);
return { data: normalizeData(res.data), total: res.total };
},
});
};
// Вот этот хук отсутствовал
export const useReview = (id: string) => {
return useQuery({
queryKey: ['reviews', id],
queryFn: () => reviewsApi.getReview(id),
enabled: !!id && id !== '-',
retry: false,
});
};
export const useUpdateReview = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Review> }) =>
reviewsApi.updateReview(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviews'] });
message.success('Отзыв обновлён');
},
onError: () => message.error('Ошибка обновления'),
});
};
export const useBulkUpdateReviews = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (updates: { id: string; status: string }[]) =>
reviewsApi.bulkUpdateReviews(updates),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['reviews'] });
message.success('Статусы обновлены');
},
onError: (error) => {
message.error('Ошибка массового обновления');
console.error(error);
},
});
};
+48
View File
@@ -0,0 +1,48 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { subscriptionsApi } from '../api/subscriptionsApi';
import { SubscriptionListParams } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
export const useSubscriptions = (params: SubscriptionListParams) => {
return useQuery({
queryKey: ['subscriptions', params],
queryFn: async () => {
const res = await subscriptionsApi.getSubscriptions(params);
return { data: normalizeData(res.data), total: res.total };
},
});
};
export const useSubscription = (id: string) => {
return useQuery({
queryKey: ['subscriptions', id],
queryFn: () => subscriptionsApi.getSubscription(id), // сырые данные
enabled: !!id,
});
};
export const useUpdateSubscription = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Subscription> }) =>
subscriptionsApi.updateSubscription(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
message.success('Подписка обновлена');
},
onError: () => message.error('Ошибка обновления'),
});
};
export const useDeleteSubscription = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => subscriptionsApi.deleteSubscription(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
message.success('Подписка удалена');
},
onError: () => message.error('Ошибка удаления'),
});
};
+58
View File
@@ -0,0 +1,58 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ticketsApi } from '../api/ticketsApi';
import { TicketListParams } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
export const useTickets = (params: TicketListParams) => {
return useQuery({
queryKey: ['tickets', params],
queryFn: async () => {
const res = await ticketsApi.getTickets(params);
return { data: normalizeData(res.data), total: res.total };
},
});
};
export const useTicket = (id: string) => {
return useQuery({
queryKey: ['tickets', id],
queryFn: () => ticketsApi.getTicket(id), // сырые данные
enabled: !!id,
});
};
export const useUpdateTicket = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Ticket> }) =>
ticketsApi.updateTicket(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
message.success('Тикет обновлён');
},
onError: () => message.error('Ошибка обновления'),
});
};
export const useDeleteTicket = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => ticketsApi.deleteTicket(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tickets'] });
message.success('Тикет удалён');
},
onError: () => message.error('Ошибка удаления'),
});
};
export const useTicketStats = () => {
return useQuery({
queryKey: ['ticket-stats'],
queryFn: async () => {
const data = await ticketsApi.getTicketStats();
return normalizeData(data);
},
});
};
+47
View File
@@ -0,0 +1,47 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { usersApi } from '../api/usersApi';
import { UserListParams } from '../types/api';
import { message } from 'antd';
import { normalizeData } from '../utils/normalize';
export const useUsers = (params: UserListParams) => {
return useQuery({
queryKey: ['users', params],
queryFn: async () => {
const res = await usersApi.getUsers(params);
return { data: normalizeData(res.data), total: res.total };
},
});
};
export const useUser = (id: string) => {
return useQuery({
queryKey: ['users', id],
queryFn: () => usersApi.getUser(id),
enabled: !!id && id !== '-',
});
};
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<User> }) => usersApi.updateUser(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
message.success('Пользователь обновлен');
},
onError: () => message.error('Ошибка обновления'),
});
};
export const useDeleteUser = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => usersApi.deleteUser(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['users'] });
message.success('Пользователь удален');
},
onError: () => message.error('Ошибка удаления'),
});
};
+111
View File
@@ -0,0 +1,111 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
}
#root {
width: 1126px;
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
body {
margin: 0;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
+281
View File
@@ -0,0 +1,281 @@
import React, { useEffect, useState } from 'react';
import { Layout, Menu, Button, theme, notification, Avatar, Dropdown, Space, Typography, Badge } from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../store/authStore';
import { useAdminWebSocket } from '../hooks/useAdminWebSocket';
import {
DashboardOutlined,
UserOutlined,
CalendarOutlined,
WarningOutlined,
StarOutlined,
StopOutlined,
BugOutlined,
DollarOutlined,
TeamOutlined,
AuditOutlined,
LogoutOutlined,
SettingOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
} from '@ant-design/icons';
const { Header, Sider, Content } = Layout;
const { Text } = Typography;
const AdminLayout: React.FC = () => {
useAdminWebSocket();
const navigate = useNavigate();
const location = useLocation();
const { user, logout } = useAuthStore();
const { token: { colorBgContainer } } = theme.useToken();
const [collapsed, setCollapsed] = useState(false);
// Обработка WebSocket-уведомлений
useEffect(() => {
const handler = (event: CustomEvent) => {
const msg = event.detail;
if (msg.type === 'report_created') {
const { report_id, target_type, target_id, reason } = msg.data || {};
notification.info({
title: 'Новая жалоба',
description: (
<span>
Жалоба{' '}
{report_id ? (
<a
href={`/reports/${report_id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/reports/${report_id}`);
}}
style={{ fontWeight: 600 }}
>
#{report_id}
</a>
) : (
'#?'
)}{' '}
на {target_type || 'неизвестный тип'}{' '}
{target_id ? (
<a
href={`/${target_type}s/${target_id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/${target_type}s/${target_id}`);
}}
style={{ fontWeight: 600 }}
>
{target_id}
</a>
) : (
'?'
)}{' '}
{reason ? `(${reason})` : ''}
</span>
),
placement: 'topRight',
});
} else if (msg.type === 'ticket_created') {
const { ticket_id } = msg.data || {};
notification.info({
title: 'Новый тикет',
description: (
<span>
Тикет{' '}
{ticket_id ? (
<a
href={`/tickets/${ticket_id}`}
onClick={(e) => {
e.preventDefault();
navigate(`/tickets/${ticket_id}`);
}}
style={{ fontWeight: 600 }}
>
#{ticket_id}
</a>
) : (
'без ID'
)}{' '}
создан
</span>
),
placement: 'topRight',
});
}
};
window.addEventListener('admin-ws-message', handler as EventListener);
return () => window.removeEventListener('admin-ws-message', handler as EventListener);
}, [navigate]);
const menuItems = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: 'Дашборд' },
{ key: '/users', icon: <UserOutlined />, label: 'Пользователи', roles: ['superadmin', 'admin'] },
{ key: '/events', icon: <CalendarOutlined />, label: 'События', roles: ['superadmin', 'admin'] },
{ key: '/reports', icon: <WarningOutlined />, label: 'Жалобы', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/reviews', icon: <StarOutlined />, label: 'Отзывы', roles: ['superadmin', 'admin', 'moderator'] },
{ key: '/banned-words', icon: <StopOutlined />, label: 'Бан-слова', roles: ['superadmin', 'admin'] },
{ key: '/tickets', icon: <BugOutlined />, label: 'Тикеты', roles: ['superadmin', 'admin', 'support'] },
{ key: '/subscriptions', icon: <DollarOutlined />, label: 'Подписки', roles: ['superadmin', 'admin'] },
{ key: '/admins', icon: <TeamOutlined />, label: 'Администраторы', roles: ['superadmin'] },
{ key: '/audit', icon: <AuditOutlined />, label: 'Аудит', roles: ['superadmin'] },
];
const filteredMenu = menuItems.filter(
(item) => !item.roles || (user && item.roles.includes(user.role))
);
const handleLogout = async () => {
await logout();
navigate('/login');
};
const getDisplayName = () => {
const nick = user?.nickname;
const email = user?.email;
if (nick && nick !== '-' && nick !== 'undefined') return nick;
if (email && email !== '-' && email !== 'undefined') return email;
return user?.id ?? '—';
};
return (
<Layout style={{ minHeight: '100vh' }}>
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={null}
style={{
position: 'relative', // чтобы абсолютный блок считался от него
display: 'flex',
flexDirection: 'column',
height: '100vh',
position: 'sticky',
top: 0,
left: 0,
}}
>
<div style={{ height: 32, margin: 16, color: 'white', textAlign: 'center', fontWeight: 'bold' }}>
{collapsed ? 'EH' : import.meta.env.VITE_APP_TITLE}
</div>
<Menu
theme="dark"
mode="inline"
selectedKeys={[location.pathname]}
items={filteredMenu}
onClick={({ key }) => navigate(key)}
style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden',
marginBottom: 60, // отступ, чтобы меню не заходило под нижний блок
}}
/>
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'space-between',
padding: '12px 24px',
borderTop: '1px solid rgba(255,255,255,0.1)',
background: '#001529', // цвет сайдбара Ant Design
flexShrink: 0,
}}
>
<Dropdown
menu={{
items: [
{
key: 'profile',
icon: <SettingOutlined />,
label: 'Мой профиль',
onClick: () => navigate('/profile'),
},
{ type: 'divider' },
{
key: 'logout',
icon: <LogoutOutlined />,
label: 'Выйти',
onClick: handleLogout,
},
],
}}
trigger={['click']}
placement="topLeft"
>
<Button
type="text"
style={{
color: 'white',
display: 'flex',
alignItems: 'center',
padding: '4px 0',
border: 'none',
width: collapsed ? 'auto' : '100%',
}}
>
<Avatar icon={<UserOutlined />} src={user?.avatar_url} size="small" />
{!collapsed && (
<Text
style={{
color: 'white',
marginLeft: 8,
maxWidth: 100,
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
ellipsis
>
{getDisplayName()}
</Text>
)}
</Button>
</Dropdown>
<Button
type="text"
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed(!collapsed)}
style={{
color: 'white',
fontSize: '16px',
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
</div>
</Sider>
<Layout>
<Header
style={{
padding: '0 24px',
background: colorBgContainer,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Space size="large">
<Badge status="success" text="Онлайн: 12" />
<Badge status="processing" text="Заказов сегодня: 5" />
<Badge status="warning" text="Тикетов: 2" />
</Space>
<div>{/* Резерв */}</div>
</Header>
<Content style={{ margin: 24 }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default AdminLayout;
+17
View File
@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import dayjs from 'dayjs';
import 'dayjs/locale/ru';
import locale from 'antd/locale/ru_RU';
import { ConfigProvider } from 'antd';
import App from './App';
dayjs.locale('ru');
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ConfigProvider locale={locale}>
<App />
</ConfigProvider>
</React.StrictMode>
);
+160
View File
@@ -0,0 +1,160 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal, Form, Input, Select, message } from 'antd';
import { EditOutlined } from '@ant-design/icons';
import { useAdmin, useUpdateAdmin } from '../../hooks/useAdmins';
import dayjs from 'dayjs';
const AdminDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: admin, isLoading } = useAdmin(id || '');
const updateAdmin = useUpdateAdmin();
const [editModal, setEditModal] = useState(false);
const [form] = Form.useForm();
// Очистка значения
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
// Заполнение формы при открытии
useEffect(() => {
if (editModal && admin) {
setTimeout(() => {
form.setFieldsValue({
nickname: clean(admin.nickname),
email: clean(admin.email),
role: clean(admin.role),
status: clean(admin.status),
timezone: clean(admin.timezone),
language: clean(admin.language),
phone: clean(admin.phone),
});
}, 0);
}
}, [editModal, admin, form]);
const handleSave = () => {
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: id!, data: payload },
{
onSuccess: () => {
setEditModal(false);
message.success('Данные обновлены');
},
}
);
});
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!admin) return <p>Администратор не найден</p>;
return (
<Card
title={`Администратор ${!isBadValue(admin.nickname) ? admin.nickname : admin.email || admin.id}`}
extra={
<Button icon={<EditOutlined />} onClick={() => setEditModal(true)}>
Редактировать
</Button>
}
>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="ID">{admin.id}</Descriptions.Item>
<Descriptions.Item label="Email">{admin.email}</Descriptions.Item>
<Descriptions.Item label="Ник">{isBadValue(admin.nickname) ? '-' : admin.nickname}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag color={roleColors[admin.role] || 'default'}>{admin.role}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={admin.status === 'active' ? 'green' : 'red'}>{admin.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{isBadValue(admin.timezone) ? '-' : admin.timezone}</Descriptions.Item>
<Descriptions.Item label="Язык">{isBadValue(admin.language) ? '-' : admin.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(admin.phone) ? '-' : admin.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">{isBadValue(admin.avatar_url) ? '-' : admin.avatar_url}</Descriptions.Item>
<Descriptions.Item label="Настройки">{isBadValue(admin.preferences) ? '-' : JSON.stringify(admin.preferences)}</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(admin.last_login)}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(admin.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(admin.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/admins')}>Назад к списку</Button>
<Modal
title="Редактировать администратора"
open={editModal}
onCancel={() => setEditModal(false)}
onOk={handleSave}
confirmLoading={updateAdmin.isPending}
destroyOnHidden
>
<Form form={form} layout="vertical" preserve={false}>
<Form.Item name="nickname" label="Ник">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item name="role" label="Роль">
<Select>
<Select.Option value="superadmin">superadmin</Select.Option>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label="Статус">
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="blocked">blocked</Select.Option>
</Select>
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default AdminDetailPage;
+305
View File
@@ -0,0 +1,305 @@
import React, { useState, useEffect } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Input, Select, Tooltip, Spin } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useAdmins, useCreateAdmin, useUpdateAdmin, useDeleteAdmin, useAdmin } from '../../hooks/useAdmins';
import { Admin, AdminListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const AdminListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<AdminListParams>({ limit: 20, offset: 0, sort: 'email', order: 'asc' });
const { data, isLoading } = useAdmins(params);
const createAdmin = useCreateAdmin();
const updateAdmin = useUpdateAdmin();
const deleteAdmin = useDeleteAdmin();
// Модальное окно создания
const [createModal, setCreateModal] = useState(false);
const [createForm] = Form.useForm();
// Модальное окно редактирования
const [editModal, setEditModal] = useState<{ open: boolean; adminId: string | null }>({
open: false,
adminId: null,
});
const { data: editingAdmin, isLoading: loadingAdmin } = useAdmin(editModal.adminId || '');
const [editForm] = Form.useForm();
// Очистка значения от "undefined" и "-"
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
// Заполнение формы редактирования при загрузке данных
useEffect(() => {
if (editModal.open && editingAdmin) {
setTimeout(() => {
editForm.setFieldsValue({
nickname: clean(editingAdmin.nickname),
email: clean(editingAdmin.email),
role: clean(editingAdmin.role),
status: clean(editingAdmin.status),
timezone: clean(editingAdmin.timezone),
language: clean(editingAdmin.language),
phone: clean(editingAdmin.phone),
});
}, 0);
}
}, [editingAdmin, editModal.open, editForm]);
const handleCreate = () => {
createForm.validateFields().then((values) => {
createAdmin.mutate(values, {
onSuccess: () => {
setCreateModal(false);
createForm.resetFields();
},
});
});
};
const handleEdit = (admin: Admin) => {
// Не сбрасываем форму вручную, destroyOnHidden очистит её после предыдущего закрытия
setEditModal({ open: true, adminId: admin.id });
};
const handleUpdate = () => {
editForm.validateFields().then((values) => {
if (!editModal.adminId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateAdmin.mutate(
{ id: editModal.adminId, data: payload },
{ onSuccess: () => setEditModal({ open: false, adminId: null }) }
);
});
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить администратора?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteAdmin.mutate(id),
});
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Admin> | SorterResult<Admin>[]
) => {
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) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const columns: ColumnsType<Admin> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу администратора">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/admins/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Email',
dataIndex: 'email',
key: 'email',
sorter: true,
ellipsis: true,
},
{
title: 'Ник',
dataIndex: 'nickname',
key: 'nickname',
sorter: true,
ellipsis: true,
},
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 120,
sorter: true,
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : 'red'}>{status}</Tag>
),
},
{
title: 'Последний вход',
dataIndex: 'last_login',
key: 'last_login',
sorter: true,
},
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
),
},
];
return (
<div>
<h2>Администраторы</h2>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateModal(true)}
style={{ marginBottom: 16 }}
>
Добавить администратора
</Button>
<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={createModal}
onCancel={() => setCreateModal(false)}
onOk={handleCreate}
confirmLoading={createAdmin.isPending}
destroyOnHidden
>
<Form form={createForm} layout="vertical" preserve={false}>
<Form.Item name="email" label="Email" rules={[{ required: true, type: 'email' }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item name="role" label="Роль" rules={[{ required: true }]}>
<Select>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* Модальное окно редактирования */}
<Modal
title="Редактировать администратора"
open={editModal.open}
onCancel={() => setEditModal({ open: false, adminId: null })}
onOk={handleUpdate}
confirmLoading={updateAdmin.isPending}
destroyOnHidden
>
{loadingAdmin ? (
<Spin />
) : (
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item name="nickname" label="Ник">
<Input />
</Form.Item>
<Form.Item name="email" label="Email">
<Input />
</Form.Item>
<Form.Item name="role" label="Роль">
<Select>
<Select.Option value="superadmin">superadmin</Select.Option>
<Select.Option value="admin">admin</Select.Option>
<Select.Option value="moderator">moderator</Select.Option>
<Select.Option value="support">support</Select.Option>
</Select>
</Form.Item>
<Form.Item name="status" label="Статус">
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="blocked">blocked</Select.Option>
</Select>
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
</Form>
)}
</Modal>
</div>
);
};
export default AdminListPage;
+244
View File
@@ -0,0 +1,244 @@
import React, { useState, useEffect } from 'react';
import { Table, Tag, Space, Select, DatePicker, Spin } from 'antd';
import { Link } from 'react-router-dom';
import { useAudit } from '../../hooks/useAudit';
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useReview } from '../../hooks/useReviews';
import { AuditRecord, AuditListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const { RangePicker } = DatePicker;
const AuditPage: React.FC = () => {
const [params, setParams] = useState<AuditListParams>({ limit: 20, offset: 0, sort: 'timestamp', order: 'desc' });
const { data, isLoading } = useAudit(params);
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
const [uniqueActions, setUniqueActions] = useState<string[]>([]);
useEffect(() => {
if (data?.data) {
const actions = new Set(data.data.map(record => record.action).filter(Boolean));
setUniqueActions(prev => {
const merged = new Set([...prev, ...actions]);
return Array.from(merged).sort();
});
}
}, [data]);
const AdminCell: React.FC<{ adminId: string }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId);
if (loading) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const EntityNameCell: React.FC<{ entityType: string; entityId: string }> = ({ entityType, entityId }) => {
const { data: user, isLoading: loadingUser } = useUser(entityType === 'user' ? entityId : '');
const { data: event, isLoading: loadingEvent } = useEvent(entityType === 'event' ? entityId : '');
const { data: review, isLoading: loadingReview } = useReview(entityType === 'review' ? entityId : '');
if (entityType === 'user') {
if (loadingUser) return <Spin size="small" />;
if (!user) return <span>{entityId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
}
if (entityType === 'event') {
if (loadingEvent) return <Spin size="small" />;
if (!event) return <span>{entityId}</span>;
const name = !isBadValue(event.title) ? event.title : event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
if (entityType === 'review') {
if (loadingReview) return <Spin size="small" />;
// Для отзыва показываем ссылку на страницу отзыва
return <Link to={`/reviews/${entityId}`}>{entityId}</Link>;
}
// Для остальных типов (calendar, report, ticket, subscription, admin) пока просто ID
return <span>{entityId}</span>;
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<AuditRecord> | SorterResult<AuditRecord>[]
) => {
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) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
const handleDateChange = (dates: any) => {
if (dates) {
setParams({
...params,
date_from: dates[0]?.toISOString(),
date_to: dates[1]?.toISOString(),
offset: 0,
});
} else {
setParams({ ...params, date_from: undefined, date_to: undefined, offset: 0 });
}
};
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const actionColors: Record<string, string> = {
create: 'green',
update: 'blue',
delete: 'red',
freeze: 'orange',
unfreeze: 'cyan',
block: 'orange',
unblock: 'cyan',
hide: 'orange',
unhide: 'cyan',
login: 'geekblue',
logout: 'default',
};
const entityTypeColors: Record<string, string> = {
user: 'blue',
event: 'green',
calendar: 'orange',
review: 'purple',
report: 'red',
ticket: 'default',
subscription: 'cyan',
admin: 'magenta',
};
const columns: ColumnsType<AuditRecord> = [
{
title: 'Админ',
key: 'admin',
render: (_, record) => <AdminCell adminId={record.admin_id} />,
},
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 120,
sorter: true,
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{
title: 'Действие',
dataIndex: 'action',
key: 'action',
width: 120,
sorter: true,
render: (action: string) => (
<Tag color={actionColors[action] || 'default'}>{action}</Tag>
),
},
{
title: 'Тип',
dataIndex: 'entity_type',
key: 'entity_type',
width: 120,
sorter: true,
render: (type: string) => (
<Tag color={entityTypeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Наименование',
key: 'entity_name',
render: (_, record) => (
<EntityNameCell entityType={record.entity_type} entityId={record.entity_id} />
),
},
{
title: 'Дата',
dataIndex: 'timestamp',
key: 'timestamp',
sorter: true,
},
{ title: 'IP', dataIndex: 'ip', key: 'ip', width: 130 },
{
title: 'Причина',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
},
];
return (
<div>
<h2>Аудит</h2>
<Space style={{ marginBottom: 16 }}>
<Select
placeholder="Администратор"
loading={loadingAdmins}
allowClear
onChange={(val) => setParams(prev => ({ ...prev, admin_id: val || undefined, offset: 0 }))}
style={{ width: 250 }}
showSearch
optionFilterProp="label"
>
{(admins?.data || []).map(admin => {
const label = admin.email || admin.id;
return (
<Select.Option key={admin.id} value={admin.id} label={label}>
{label}
</Select.Option>
);
})}
</Select>
<Select
placeholder="Действие"
allowClear
onChange={(val) => setParams(prev => ({ ...prev, action: val || undefined, offset: 0 }))}
style={{ width: 200 }}
>
{uniqueActions.map(action => (
<Select.Option key={action} value={action}>{action}</Select.Option>
))}
</Select>
<RangePicker
onChange={handleDateChange}
allowClear
/>
</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,
}}
/>
</div>
);
};
export default AuditPage;
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
import { Form, Input, Button, Card, message } from 'antd';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '../../store/authStore';
const LoginPage: React.FC = () => {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const onFinish = async (values: { email: string; password: string }) => {
console.log('Form submitted', values);
try {
await login(values.email, values.password);
navigate('/dashboard');
} catch (error) {
message.error('Ошибка входа');
}
};
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Card title="Вход" style={{ width: 400 }}>
<Form onFinish={onFinish} layout="vertical">
<Form.Item name="email" label="Email" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="Пароль" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
Войти
</Button>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default LoginPage;
+133
View File
@@ -0,0 +1,133 @@
import React, { useState } from 'react';
import { Table, Button, Input, Space, Popconfirm, Tooltip, Spin } from 'antd';
import { SearchOutlined, DeleteOutlined } from '@ant-design/icons';
import { Link } from 'react-router-dom';
import { useBannedWords, useAddBannedWord, useRemoveBannedWord, BannedWordListParams } from '../../hooks/useBannedWords';
import { useAdmin } from '../../hooks/useAdmins';
import { BannedWord } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table';
import dayjs from 'dayjs';
const BannedWordsPage: React.FC = () => {
const [params, setParams] = useState<BannedWordListParams>({ limit: 20, offset: 0 });
const { data, isLoading } = useBannedWords(params);
const addWord = useAddBannedWord();
const removeWord = useRemoveBannedWord();
const [newWord, setNewWord] = useState('');
const handleAdd = () => {
if (newWord.trim()) {
addWord.mutate(newWord.trim(), {
onSuccess: () => setNewWord(''),
});
}
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<BannedWord> | SorterResult<BannedWord>[]
) => {
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) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
// Компонент для отображения "Кем добавлено"
const AddedByCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loadingAdmin } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loadingAdmin) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = admin.nickname && admin.nickname !== '-' ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{name || admin.id}</Link>;
};
const columns: ColumnsType<BannedWord> = [
{
title: 'Слово',
dataIndex: 'word',
key: 'word',
sorter: true,
},
{
title: 'Кем добавлено',
dataIndex: 'added_by',
key: 'added_by',
render: (addedBy: string) => <AddedByCell adminId={addedBy} />,
},
{
title: 'Дата добавления',
dataIndex: 'added_at',
key: 'added_at',
sorter: true,
render: (date: string) => {
if (!date || date === '-' || date === 'undefined') return '-';
return dayjs(date).format('DD.MM.YYYY HH:mm');
},
},
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm
title="Удалить слово?"
onConfirm={() => removeWord.mutate(record.word)}
>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
/>
</Tooltip>
</Popconfirm>
),
},
];
return (
<div>
<h2>Бан-слова</h2>
<Space style={{ marginBottom: 16 }}>
<Input
placeholder="Новое слово"
value={newWord}
onChange={(e) => setNewWord(e.target.value)}
onPressEnter={handleAdd}
style={{ width: 200 }}
/>
<Button type="primary" onClick={handleAdd} loading={addWord.isPending}>
Добавить
</Button>
<Input.Search
placeholder="Поиск по слову"
onSearch={(value) => setParams(prev => ({ ...prev, q: value || undefined, offset: 0 }))}
style={{ width: 200 }}
allowClear
/>
</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,
}}
/>
</div>
);
};
export default BannedWordsPage;
+238
View File
@@ -0,0 +1,238 @@
import React from 'react';
import { Card, Col, Row, Statistic, Spin, Alert, Table, Tag } from 'antd';
import {
UserOutlined,
CalendarOutlined,
StarOutlined,
TeamOutlined,
WarningOutlined,
BugOutlined,
ClockCircleOutlined,
} from '@ant-design/icons';
import { useDashboardStats } from '../../hooks/useDashboard';
import type { ColumnsType } from 'antd/es/table';
import { AdminActivity } from '../../types/api';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
AreaChart,
Area,
} from 'recharts';
const DashboardPage: React.FC = () => {
const { data, isLoading, error } = useDashboardStats();
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (error) return <Alert type="error" message="Ошибка загрузки статистики" />;
if (!data) return null;
const roleColors: Record<string, string> = {
superadmin: 'red',
admin: 'blue',
moderator: 'purple',
support: 'cyan',
};
const adminColumns: ColumnsType<AdminActivity> = [
{ title: 'Email', dataIndex: 'email', key: 'email' },
{ title: 'Ник', dataIndex: 'nickname', key: 'nickname' },
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
render: (role: string) => (
<Tag color={roleColors[role] || 'default'}>{role}</Tag>
),
},
{ title: 'Действий', dataIndex: 'actions', key: 'actions' },
{ title: 'Последний вход', dataIndex: 'last_login', key: 'last_login' },
];
const eventsChartData = data.events_by_day?.map(item => ({
date: item.date,
events: item.count,
})) || [];
const registrationsChartData = data.registrations_by_day?.map(item => ({
date: item.date,
registrations: item.count,
})) || [];
return (
<div>
<h2>Общая статистика</h2>
<Row gutter={[16, 16]}>
{/* Пользователи с мини-графиком */}
<Col xs={24} sm={12} lg={6}>
<Card>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Statistic
title="Пользователи"
value={data.users_total}
prefix={<UserOutlined />}
style={{ flex: '0 0 auto', marginRight: 16 }}
/>
{registrationsChartData.length > 0 && (
<AreaChart
width={150}
height={50}
data={registrationsChartData}
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorRegistrations" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#52c41a" stopOpacity={0.4} />
<stop offset="95%" stopColor="#52c41a" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
<Area
type="monotone"
dataKey="registrations"
name="Регистрации"
stroke="#52c41a"
fill="url(#colorRegistrations)"
strokeWidth={2}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
/>
</AreaChart>
)}
</div>
</Card>
</Col>
{/* События с мини-графиком */}
<Col xs={24} sm={12} lg={6}>
<Card>
<div style={{ display: 'flex', alignItems: 'center' }}>
<Statistic
title="События"
value={data.events_total}
prefix={<TeamOutlined />}
style={{ flex: '0 0 auto', marginRight: 16 }}
/>
{eventsChartData.length > 0 && (
<AreaChart
width={150}
height={50}
data={eventsChartData}
margin={{ top: 5, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorEvents" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#1890ff" stopOpacity={0.4} />
<stop offset="95%" stopColor="#1890ff" stopOpacity={0.05} />
</linearGradient>
</defs>
<XAxis dataKey="date" hide />
<Tooltip labelFormatter={(label) => `Дата: ${label}`} />
<Area
type="monotone"
dataKey="events"
name="События"
stroke="#1890ff"
fill="url(#colorEvents)"
strokeWidth={2}
dot={false}
activeDot={{ r: 3, strokeWidth: 0 }}
/>
</AreaChart>
)}
</div>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Отзывы" value={data.reviews_total} prefix={<StarOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Календари" value={data.calendars_total} prefix={<CalendarOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Жалобы" value={data.reports_total} prefix={<WarningOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic title="Тикеты (всего)" value={data.tickets_total} prefix={<BugOutlined />} />
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Открытых тикетов"
value={data.tickets_open}
prefix={<ClockCircleOutlined />}
/>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Statistic
title="Среднее время решения (ч)"
value={data.avg_ticket_resolution_h}
precision={1}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginTop: 32 }}>
<Col xs={24} lg={12}>
<Card title="События по дням">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={eventsChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="events" stroke="#1890ff" name="События" />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
<Col xs={24} lg={12}>
<Card title="Регистрации по дням">
<ResponsiveContainer width="100%" height={300}>
<LineChart data={registrationsChartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="date" />
<YAxis allowDecimals={false} />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="registrations" stroke="#52c41a" name="Регистрации" />
</LineChart>
</ResponsiveContainer>
</Card>
</Col>
</Row>
<h2 style={{ marginTop: 32 }}>Активность администраторов</h2>
<Table
columns={adminColumns}
dataSource={data.admin_activity}
rowKey="admin_id"
pagination={false}
size="small"
/>
</div>
);
};
export default DashboardPage;
+85
View File
@@ -0,0 +1,85 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space } from 'antd';
import { Link } from 'react-router-dom';
import { useEvent } from '../../hooks/useEvents';
import dayjs from 'dayjs';
const EventDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: event, isLoading } = useEvent(id || '');
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => isBadValue(val) ? '-' : 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 (!event) return <p>Событие не найдено</p>;
// Подготовка сложных полей
const tagsStr = Array.isArray(event.tags) && event.tags.length > 0 ? event.tags.join(', ') : '-';
const recurrenceStr = event.recurrence ? `${(event.recurrence as any).freq || ''} (интервал: ${(event.recurrence as any).interval || ''})` : '-';
const locationStr = event.location ? JSON.stringify(event.location) : '-';
const attachmentsStr = !isBadValue(event.attachments) ? JSON.stringify(event.attachments) : '-';
const editHistoryStr = !isBadValue(event.edit_history) ? JSON.stringify(event.edit_history) : '-';
// ID-ссылки
const specialistLink = !isBadValue(event.specialist_id) ? (
<Link to={`/users/${event.specialist_id}`}>{event.specialist_id}</Link>
) : '-';
const calendarLink = !isBadValue(event.calendar_id) ? (
// Пока нет страницы календаря, просто ID, но можно обернуть в ссылку, если появится
<span>{event.calendar_id}</span>
) : '-';
const masterLink = !isBadValue(event.master_id) ? (
<Link to={`/events/${event.master_id}`}>{event.master_id}</Link>
) : '-';
return (
<Card title={`Событие ${event.title || event.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{event.id}</Descriptions.Item>
<Descriptions.Item label="Название">{event.title}</Descriptions.Item>
<Descriptions.Item label="Описание">{displayValue(event.description) || '-'}</Descriptions.Item>
<Descriptions.Item label="Тип">
<Tag color={event.event_type === 'single' ? 'blue' : event.event_type === 'recurring' ? 'purple' : 'default'}>
{event.event_type}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={event.status === 'active' ? 'green' : event.status === 'cancelled' ? 'red' : 'gray'}>
{event.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(event.reason)}</Descriptions.Item>
<Descriptions.Item label="Дата и время начала">{formatDate(event.start_time)}</Descriptions.Item>
<Descriptions.Item label="Продолжительность (мин)">{event.duration ?? '-'}</Descriptions.Item>
<Descriptions.Item label="Вместимость">{displayValue(event.capacity)}</Descriptions.Item>
<Descriptions.Item label="Специалист">{specialistLink}</Descriptions.Item>
<Descriptions.Item label="Календарь">{calendarLink}</Descriptions.Item>
<Descriptions.Item label="Мастер-событие">{masterLink}</Descriptions.Item>
<Descriptions.Item label="Онлайн-ссылка">{displayValue(event.online_link)}</Descriptions.Item>
<Descriptions.Item label="Теги">{tagsStr}</Descriptions.Item>
<Descriptions.Item label="Рейтинг">{event.rating_avg} ({event.rating_count} оценок)</Descriptions.Item>
<Descriptions.Item label="Вложения">{attachmentsStr}</Descriptions.Item>
<Descriptions.Item label="История изменений">{editHistoryStr}</Descriptions.Item>
<Descriptions.Item label="Повторение">{recurrenceStr}</Descriptions.Item>
<Descriptions.Item label="Локация">{locationStr}</Descriptions.Item>
<Descriptions.Item label="Является экземпляром">{event.is_instance ? 'Да' : 'Нет'}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(event.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(event.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/events')}>Назад к списку</Button>
</Card>
);
};
export default EventDetailPage;
+291
View File
@@ -0,0 +1,291 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, message, Spin, Input, Tooltip, DatePicker, InputNumber } from 'antd';
import { InfoCircleOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useEvents, useUpdateEvent, useDeleteEvent, useEvent } from '../../hooks/useEvents';
import { Event, EventListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
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 updateEvent = useUpdateEvent();
const deleteEvent = useDeleteEvent();
const [editModal, setEditModal] = useState<{ open: boolean; eventId: string | null }>({
open: false,
eventId: null,
});
const { data: editingEvent, isLoading: loadingEvent } = useEvent(editModal.eventId || '');
const [editForm] = Form.useForm();
const [editHasErrors, setEditHasErrors] = useState(true);
const originalEventRef = useRef<Event | null>(null);
const handleEdit = (id: string) => {
setEditModal({ open: true, eventId: id });
};
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.eventId || !originalEventRef.current) return;
const original = originalEventRef.current;
const cleanedValues: Record<string, any> = {};
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 any)[key];
if (origVal && origVal !== '-' && origVal !== '' && origVal !== undefined && origVal !== null) {
cleanedValues[key] = null;
}
return;
}
if (newVal === '-') return;
if (dayjs.isDayjs(newVal)) {
cleanedValues[key] = newVal.toISOString();
} else {
cleanedValues[key] = newVal;
}
});
const payload = Object.fromEntries(
Object.entries(cleanedValues).filter(([key, v]) => {
const origVal = (original as any)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateEvent.mutate(
{ id: editModal.eventId, data: payload },
{ onSuccess: () => setEditModal({ open: false, eventId: null }) }
);
});
};
useEffect(() => {
if (editingEvent && editModal.open) {
originalEventRef.current = editingEvent;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
setTimeout(() => {
editForm.setFieldsValue({
title: clean(editingEvent.title),
description: clean(editingEvent.description),
event_type: clean(editingEvent.event_type),
status: clean(editingEvent.status),
start_time: clean(editingEvent.start_time) ? dayjs(editingEvent.start_time) : null,
duration: editingEvent.duration,
capacity: editingEvent.capacity,
specialist_id: clean(editingEvent.specialist_id),
calendar_id: clean(editingEvent.calendar_id),
online_link: clean(editingEvent.online_link),
tags: editingEvent.tags,
});
validateEditForm();
}, 0);
}
}, [editingEvent, editModal.open, editForm]);
const validateEditForm = () => {
const title = editForm.getFieldValue('title');
const status = editForm.getFieldValue('status');
setEditHasErrors(!title || !status);
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить событие?',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteEvent.mutate(id),
});
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Event> | SorterResult<Event>[]
) => {
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) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
const typeColors: Record<string, string> = {
single: 'blue',
recurring: 'purple',
};
const columns: ColumnsType<Event> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу события">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/events/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Название',
dataIndex: 'title',
key: 'title',
ellipsis: true,
sorter: true,
},
{
title: 'Тип',
dataIndex: 'event_type',
key: 'event_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={typeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : status === 'cancelled' ? 'red' : 'gray'}>
{status}
</Tag>
),
},
{
title: 'Начало',
dataIndex: 'start_time',
key: 'start_time',
width: 130,
sorter: true,
render: (time: string) => time ? dayjs(time).format('DD.MM.YYYY HH:mm') : '-',
},
{
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>
<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, eventId: null })}
onOk={handleSaveEdit}
confirmLoading={updateEvent.isPending}
destroyOnHidden
width={640}
okButtonProps={{ disabled: editHasErrors }}
>
{loadingEvent ? (
<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="event_type">
<Select>
<Select.Option value="single">single</Select.Option>
<Select.Option value="recurring">recurring</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="cancelled">cancelled</Select.Option>
<Select.Option value="completed">completed</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Дата и время начала" name="start_time">
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" />
</Form.Item>
<Form.Item label="Продолжительность (мин)" name="duration">
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Вместимость" name="capacity">
<InputNumber min={0} />
</Form.Item>
<Form.Item label="Специалист (ID)" name="specialist_id">
<Input />
</Form.Item>
<Form.Item label="Календарь (ID)" name="calendar_id">
<Input />
</Form.Item>
<Form.Item label="Ссылка онлайн" name="online_link">
<Input />
</Form.Item>
<Form.Item label="Теги" name="tags">
<Select mode="tags" placeholder="Введите теги" />
</Form.Item>
</Form>
)}
</Modal>
</div>
);
};
export default EventListPage;
+201
View File
@@ -0,0 +1,201 @@
import React, { useState } from 'react';
import { Card, Descriptions, Spin, Button, Form, Input, Select, Tag, message, Space, Avatar } from 'antd';
import { UserOutlined } from '@ant-design/icons';
import { useAuthStore } from '../../store/authStore';
import { useUpdateAdmin } from '../../hooks/useAdmins';
import dayjs from 'dayjs';
const ProfilePage: React.FC = () => {
const { user } = useAuthStore();
const updateAdmin = useUpdateAdmin();
const [editing, setEditing] = useState(false);
const [form] = Form.useForm();
// Проверка, является ли текущий пользователь superadmin
const isSuperadmin = user?.role === 'superadmin';
// Заполняем форму при входе в режим редактирования
const startEditing = () => {
if (!user) return;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
form.setFieldsValue({
nickname: clean(user.nickname),
email: clean(user.email),
timezone: clean(user.timezone),
language: clean(user.language),
phone: clean(user.phone),
avatar_url: clean(user.avatar_url),
preferences: user.preferences && !isBadValue(user.preferences) ? JSON.stringify(user.preferences) : '',
});
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);
} catch {
message.error('Поле «Настройки» должно быть валидным JSON');
return;
}
}
updateAdmin.mutate(
{ id: user.id, data: payload },
{
onSuccess: () => {
message.success('Профиль обновлён');
setEditing(false);
},
}
);
});
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
if (!user) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
return (
<Card title="Мой профиль">
{!editing ? (
<>
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
<Avatar size={64} icon={<UserOutlined />} src={isBadValue(user.avatar_url) ? undefined : user.avatar_url} />
<div>
<h3>{!isBadValue(user.nickname) ? user.nickname : user.email}</h3>
<Tag
color={
user.role === 'superadmin'
? 'red'
: user.role === 'admin'
? 'blue'
: user.role === 'moderator'
? 'purple'
: 'cyan'
}
>
{user.role}
</Tag>
</div>
</div>
<Descriptions bordered column={1}>
<Descriptions.Item label="Email">{user.email}</Descriptions.Item>
<Descriptions.Item label="Ник">{isBadValue(user.nickname) ? '-' : user.nickname}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag
color={
user.role === 'superadmin'
? 'red'
: user.role === 'admin'
? 'blue'
: user.role === 'moderator'
? 'purple'
: 'cyan'
}
>
{user.role}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={user.status === 'active' ? 'green' : 'red'}>{user.status}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{isBadValue(user.timezone) ? '-' : user.timezone}</Descriptions.Item>
<Descriptions.Item label="Язык">{isBadValue(user.language) ? '-' : user.language}</Descriptions.Item>
<Descriptions.Item label="Телефон">{isBadValue(user.phone) ? '-' : user.phone}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">
{isBadValue(user.avatar_url) ? '-' : <a href={user.avatar_url} target="_blank" rel="noreferrer">{user.avatar_url}</a>}
</Descriptions.Item>
<Descriptions.Item label="Настройки">
{isBadValue(user.preferences) ? '-' : <pre>{JSON.stringify(user.preferences, null, 2)}</pre>}
</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
</Descriptions>
{isSuperadmin && (
<Button type="primary" style={{ marginTop: 16 }} onClick={startEditing}>
Редактировать
</Button>
)}
</>
) : (
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item label="Email" name="email">
<Input disabled />
</Form.Item>
<Form.Item label="Ник" name="nickname">
<Input />
</Form.Item>
<Form.Item label="Роль" name="role">
<Input disabled />
</Form.Item>
<Form.Item label="Статус" name="status">
<Input disabled />
</Form.Item>
<Form.Item name="timezone" label="Часовой пояс">
<Select placeholder="Выберите пояс" allowClear>
<Select.Option value="UTC">UTC</Select.Option>
<Select.Option value="Europe/Moscow">Europe/Moscow (Москва)</Select.Option>
<Select.Option value="Europe/London">Europe/London (Лондон)</Select.Option>
<Select.Option value="Europe/Berlin">Europe/Berlin (Берлин)</Select.Option>
<Select.Option value="America/New_York">America/New_York (Нью-Йорк)</Select.Option>
<Select.Option value="Asia/Tokyo">Asia/Tokyo (Токио)</Select.Option>
<Select.Option value="Asia/Shanghai">Asia/Shanghai (Шанхай)</Select.Option>
<Select.Option value="Australia/Sydney">Australia/Sydney (Сидней)</Select.Option>
</Select>
</Form.Item>
<Form.Item name="language" label="Язык">
<Select placeholder="Выберите язык" allowClear>
<Select.Option value="ru">Русский</Select.Option>
<Select.Option value="en">English</Select.Option>
</Select>
</Form.Item>
<Form.Item name="phone" label="Телефон">
<Input placeholder="+7 (999) 123-45-67" />
</Form.Item>
<Form.Item name="avatar_url" label="URL аватара">
<Input placeholder="https://example.com/avatar.jpg" />
</Form.Item>
<Form.Item
name="preferences"
label="Настройки (JSON)"
rules={[
{
validator: (_, value) => {
if (value && value.trim().length > 0) {
try {
JSON.parse(value);
} catch {
return Promise.reject(new Error('Невалидный JSON'));
}
}
return Promise.resolve();
},
},
]}
>
<Input.TextArea rows={4} placeholder='{"key": "value"}' />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={updateAdmin.isPending}>
Сохранить
</Button>
<Button onClick={() => setEditing(false)}>Отмена</Button>
</Space>
</Form>
)}
</Card>
);
};
export default ProfilePage;
+98
View File
@@ -0,0 +1,98 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal } from 'antd';
import { Link } from 'react-router-dom';
import { useReport, useUpdateReport } from '../../hooks/useReports';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins';
const ReportDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: report, isLoading: reportLoading } = useReport(id || '');
const updateReport = useUpdateReport();
const reporterId = report?.reporter_id;
const resolvedById = report?.resolved_by;
const targetType = report?.target_type;
const targetId = report?.target_id;
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetId || '');
const handleStatusChange = (status: 'reviewed' | 'dismissed') => {
Modal.confirm({
title: `Отметить как «${status === 'reviewed' ? 'Рассмотрено' : 'Отклонено'}»?`,
okText: 'Да',
cancelText: 'Отмена',
onOk: () => updateReport.mutate({ id: id!, data: { status } }),
});
};
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const getLink = (entity: any, type: 'user' | 'admin' | 'event') => {
if (!entity) return <Spin size="small" />;
let name = '';
if (type === 'event') {
name = !isBadValue(entity.title) ? entity.title : entity.id;
} else {
const nick = entity.nickname;
const email = entity.email;
if (!isBadValue(nick)) name = nick;
else if (!isBadValue(email)) name = email;
else name = entity.id;
}
const to = type === 'user' ? `/users/${entity.id}` : type === 'admin' ? `/admins/${entity.id}` : `/events/${entity.id}`;
return <Link to={to}>{name}</Link>;
};
const displayId = (val?: string | null) => (!val || isBadValue(val)) ? '-' : val;
const isValidId = (val?: string | null) => val && !isBadValue(val);
if (reportLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!report) return <p>Жалоба не найдена</p>;
return (
<Card title={`Жалоба ${report.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{report.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">
{loadingReporter && isValidId(reporterId) ? <Spin size="small" /> : (
reporter ? getLink(reporter, 'user') : displayId(reporterId)
)}
</Descriptions.Item>
<Descriptions.Item label="Тип цели">{report.target_type}</Descriptions.Item>
<Descriptions.Item label="Цель">
{targetType === 'event' && loadingEvent && isValidId(targetId) ? <Spin size="small" /> : (
targetType === 'event' && targetEvent ? getLink(targetEvent, 'event') : displayId(targetId)
)}
</Descriptions.Item>
<Descriptions.Item label="Причина">{report.reason}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={report.status === 'pending' ? 'orange' : report.status === 'reviewed' ? 'green' : 'default'}>
{report.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Создано">{report.created_at}</Descriptions.Item>
<Descriptions.Item label="Решено">{report.resolved_at || '-'}</Descriptions.Item>
<Descriptions.Item label="Кем решено">
{loadingResolver && isValidId(resolvedById) ? <Spin size="small" /> : (
resolvedAdmin ? getLink(resolvedAdmin, 'admin') : displayId(resolvedById)
)}
</Descriptions.Item>
</Descriptions>
{report.status === 'pending' && (
<Space style={{ marginTop: 16 }}>
<Button type="primary" onClick={() => handleStatusChange('reviewed')} loading={updateReport.isPending}>Рассмотрено</Button>
<Button danger onClick={() => handleStatusChange('dismissed')} loading={updateReport.isPending}>Отклонено</Button>
</Space>
)}
<Button style={{ marginTop: 16 }} onClick={() => navigate('/reports')}>Назад к списку</Button>
</Card>
);
};
export default ReportDetailPage;
+293
View File
@@ -0,0 +1,293 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Modal, Descriptions, Tooltip, Spin, Space } from 'antd';
import { InfoCircleOutlined, EyeOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useReports, useUpdateReport } from '../../hooks/useReports';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { useAdmin } from '../../hooks/useAdmins';
import { Report, ReportListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
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 updateReport = useUpdateReport();
const [detailModal, setDetailModal] = useState<{ open: boolean; report: Report | null }>({
open: false,
report: null,
});
const reporterId = detailModal.report?.reporter_id;
const resolvedById = detailModal.report?.resolved_by;
const targetType = detailModal.report?.target_type;
const targetId = detailModal.report?.target_id;
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const { data: resolvedAdmin, isLoading: loadingResolver } = useAdmin(resolvedById || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetId || '');
const handleViewDetails = (report: Report) => {
setDetailModal({ open: true, report });
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Report> | SorterResult<Report>[]
) => {
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) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
// Цвета для типа цели
const targetTypeColors: Record<string, string> = {
event: 'blue',
calendar: 'green',
review: 'purple',
};
// Резолвер отправителя
const ReporterCell: React.FC<{ reporterId: string }> = ({ reporterId }) => {
const { data: user, isLoading: loading } = useUser(reporterId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{reporterId}</span>;
// Используем ту же логику, что и в renderLink
const nick = user.nickname;
const email = user.email;
let name = '';
if (!isBadValue(nick)) {
name = nick;
} else if (!isBadValue(email)) {
name = email;
} else {
name = user.id;
}
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
// Резолвер цели
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
if (targetType === 'event') {
if (loading) return <Spin size="small" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
const columns: ColumnsType<Report> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу жалобы">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/reports/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Отправитель',
key: 'reporter',
ellipsis: true,
render: (_, record) => <ReporterCell reporterId={record.reporter_id} />,
},
{
title: 'Тип',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={targetTypeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Цель',
key: 'target',
ellipsis: true,
render: (_, record) => <TargetCell targetType={record.target_type} targetId={record.target_id} />,
},
{
title: 'Причина',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'pending' ? 'orange' : status === 'reviewed' ? 'green' : 'default'}>
{status}
</Tag>
),
},
{
title: 'Создано',
dataIndex: 'created_at',
key: 'created_at',
width: 120,
sorter: true,
},
{
title: <EyeOutlined />,
key: 'view',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Быстрый просмотр">
<Button
icon={<EyeOutlined />}
size="small"
onClick={() => handleViewDetails(record)}
/>
</Tooltip>
),
},
];
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const renderLink = (entity: any, type: 'user' | 'admin' | 'event') => {
if (!entity) return <Spin size="small" />;
let name = '';
if (type === 'event') {
name = !isBadValue(entity.title) ? entity.title : entity.id;
} else {
const nick = entity.nickname;
const email = entity.email;
if (!isBadValue(nick)) {
name = nick;
} else if (!isBadValue(email)) {
name = email;
} else {
name = entity.id;
}
}
const to =
type === 'user' ? `/users/${entity.id}` :
type === 'admin' ? `/admins/${entity.id}` :
`/events/${entity.id}`;
return <Link to={to}>{name}</Link>;
};
const displayId = (id?: string | null) => {
if (!id || isBadValue(id)) return '-';
return id;
};
const isValidId = (id?: string | null) => id && !isBadValue(id);
return (
<div>
<h2>Жалобы</h2>
<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={`Жалоба ${detailModal.report?.id || ''}`}
open={detailModal.open}
onCancel={() => setDetailModal({ open: false, report: null })}
footer={null}
width={600}
>
{detailModal.report && (
<>
<Descriptions bordered column={1} size="small">
<Descriptions.Item label="ID">{detailModal.report.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">
{loadingReporter && isValidId(reporterId) ? <Spin size="small" /> : (
reporter ? renderLink(reporter, 'user') : displayId(reporterId)
)}
</Descriptions.Item>
<Descriptions.Item label="Тип цели">{detailModal.report.target_type}</Descriptions.Item>
<Descriptions.Item label="Цель">
{targetType === 'event' && loadingEvent && isValidId(targetId) ? <Spin size="small" /> : (
targetType === 'event' && targetEvent ? renderLink(targetEvent, 'event') : displayId(targetId)
)}
</Descriptions.Item>
<Descriptions.Item label="Причина">{detailModal.report.reason}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={detailModal.report.status === 'pending' ? 'orange' : detailModal.report.status === 'reviewed' ? 'green' : 'default'}>
{detailModal.report.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Создано">{detailModal.report.created_at}</Descriptions.Item>
<Descriptions.Item label="Решено">{detailModal.report.resolved_at || '-'}</Descriptions.Item>
<Descriptions.Item label="Кем решено">
{loadingResolver && isValidId(resolvedById) ? <Spin size="small" /> : (
resolvedAdmin ? renderLink(resolvedAdmin, 'admin') : displayId(resolvedById)
)}
</Descriptions.Item>
</Descriptions>
{detailModal.report.status === 'pending' && (
<Space style={{ marginTop: 16 }}>
<Button
type="primary"
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'reviewed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
loading={updateReport.isPending}
>
Рассмотрено
</Button>
<Button
danger
onClick={() => {
updateReport.mutate(
{ id: detailModal.report!.id, data: { status: 'dismissed' } },
{ onSuccess: () => setDetailModal({ open: false, report: null }) }
);
}}
loading={updateReport.isPending}
>
Отклонено
</Button>
</Space>
)}
</>
)}
</Modal>
</div>
);
};
export default ReportListPage;
+144
View File
@@ -0,0 +1,144 @@
import React, { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Modal, Form, Select, Input, message } from 'antd';
import { Link } from 'react-router-dom';
import { useReview, useUpdateReview } from '../../hooks/useReviews';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import dayjs from 'dayjs';
const ReviewDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: review, isLoading: reviewLoading } = useReview(id || '');
const updateReview = useUpdateReview();
const userId = review?.user_id;
const targetType = review?.target_type;
const targetId = review?.target_id;
const { data: user, isLoading: loadingUser } = useUser(userId || '');
const { data: targetEvent, isLoading: loadingEvent } = useEvent(targetType === 'event' ? targetId || '' : '');
const [statusModal, setStatusModal] = useState<{
open: boolean;
newStatus: string;
}>({ open: false, newStatus: 'visible' });
const [statusForm] = Form.useForm();
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => isBadValue(val) ? '-' : val;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const getUserLink = () => {
if (loadingUser) return <Spin size="small" />;
if (!user) return displayValue(userId);
const name = !isBadValue(user.nickname) ? user.nickname : user.email || user.id;
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
const getTargetLink = () => {
if (targetType === 'event') {
if (loadingEvent) return <Spin size="small" />;
if (targetEvent) {
const name = targetEvent.title || targetEvent.id;
return <Link to={`/events/${targetEvent.id}`}>{name}</Link>;
}
return displayValue(targetId);
}
return displayValue(targetId);
};
// Открытие модалки смены статуса
const openStatusModal = (newStatus: string) => {
setStatusModal({ open: true, newStatus });
statusForm.resetFields();
// Предзаполняем причину, если была
const currentReason = review?.reason && !isBadValue(review.reason) ? review.reason : '';
statusForm.setFieldsValue({ reason: currentReason });
};
const handleStatusSubmit = () => {
statusForm.validateFields().then((values) => {
if (!review) return;
const payload: any = { status: statusModal.newStatus };
if (values.reason && values.reason.trim() !== '') {
payload.reason = values.reason;
}
updateReview.mutate(
{ id: review.id, data: payload },
{
onSuccess: () => {
setStatusModal({ open: false, newStatus: 'visible' });
message.success('Статус обновлён');
}
}
);
});
};
if (reviewLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!review) return <p>Отзыв не найден</p>;
return (
<Card title={`Отзыв ${review.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{review.id}</Descriptions.Item>
<Descriptions.Item label="Пользователь">{getUserLink()}</Descriptions.Item>
<Descriptions.Item label="Тип цели">
<Tag color={review.target_type === 'event' ? 'blue' : 'green'}>{review.target_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Цель">{getTargetLink()}</Descriptions.Item>
<Descriptions.Item label="Оценка">{'⭐'.repeat(review.rating)} ({review.rating})</Descriptions.Item>
<Descriptions.Item label="Комментарий">{displayValue(review.comment)}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={review.status === 'visible' ? 'green' : review.status === 'hidden' ? 'orange' : 'red'}>
{review.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(review.reason)}</Descriptions.Item>
<Descriptions.Item label="Лайки">{isBadValue(review.likes) ? 0 : review.likes}</Descriptions.Item>
<Descriptions.Item label="Дизлайки">{isBadValue(review.dislikes) ? 0 : review.dislikes}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(review.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(review.updated_at)}</Descriptions.Item>
</Descriptions>
<Space style={{ marginTop: 16 }}>
<Button onClick={() => openStatusModal('visible')}>Показать</Button>
<Button onClick={() => openStatusModal('hidden')}>Скрыть</Button>
<Button danger onClick={() => openStatusModal('deleted')}>Удалить</Button>
</Space>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/reviews')}>Назад к списку</Button>
<Modal
title={`Изменить статус на «${statusModal.newStatus}»`}
open={statusModal.open}
onCancel={() => setStatusModal({ open: false, newStatus: 'visible' })}
onOk={handleStatusSubmit}
confirmLoading={updateReview.isPending}
destroyOnHidden
>
<Form form={statusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: statusModal.newStatus !== 'visible',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Введите причину изменения статуса" />
</Form.Item>
</Form>
</Modal>
</Card>
);
};
export default ReviewDetailPage;
+345
View File
@@ -0,0 +1,345 @@
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 { Link, useNavigate } from 'react-router-dom';
import { useReviews, useUpdateReview, useBulkUpdateReviews, useReview } from '../../hooks/useReviews';
import { useUser } from '../../hooks/useUsers';
import { useEvent } from '../../hooks/useEvents';
import { Review, ReviewListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
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 updateReview = useUpdateReview();
const bulkUpdate = useBulkUpdateReviews();
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
// Модальное окно для индивидуального редактирования
const [editModal, setEditModal] = useState<{ open: boolean; reviewId: string | null }>({
open: false,
reviewId: null,
});
const [editForm] = Form.useForm();
const { data: editingReview, isLoading: loadingReview } = useReview(editModal.reviewId || '');
// Модальное окно для массового изменения статуса с причиной
const [bulkStatusModal, setBulkStatusModal] = useState<{
open: boolean;
status: string;
}>({ open: false, status: 'hidden' });
const [bulkStatusForm] = Form.useForm();
// ---- Индивидуальное редактирование ----
const handleEdit = (id: string) => {
setEditModal({ open: true, reviewId: id });
};
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.reviewId) return;
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateReview.mutate(
{ id: editModal.reviewId, data: payload },
{ onSuccess: () => setEditModal({ open: false, reviewId: null }) }
);
});
};
useEffect(() => {
if (editingReview && editModal.open) {
setTimeout(() => {
editForm.setFieldsValue({
status: editingReview.status,
reason: editingReview.reason,
comment: editingReview.comment,
rating: editingReview.rating,
});
}, 0);
}
}, [editingReview, editModal.open, editForm]);
// ---- Массовое изменение статуса ----
const openBulkStatusModal = (status: string) => {
if (selectedRowKeys.length === 0) {
message.warning('Выберите отзывы');
return;
}
setBulkStatusModal({ open: true, status });
bulkStatusForm.resetFields();
};
const handleBulkStatusSubmit = () => {
bulkStatusForm.validateFields().then((values) => {
const reason = values.reason ? values.reason.trim() : '';
const updates = selectedRowKeys.map(id => ({
id: id as string,
status: bulkStatusModal.status,
reason: reason || undefined, // если пусто, не передаем
}));
bulkUpdate.mutate(updates, {
onSuccess: () => {
setBulkStatusModal({ open: false, status: 'hidden' });
setSelectedRowKeys([]);
},
});
});
};
// ---- Таблица ----
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Review> | SorterResult<Review>[]
) => {
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) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{userId}</span>;
const nick = user.nickname;
const email = user.email;
let name = '';
if (!isBadValue(nick)) {
name = nick;
} else if (!isBadValue(email)) {
name = email;
} else {
name = user.id;
}
return <Link to={`/users/${user.id}`}>{name}</Link>;
};
const TargetCell: React.FC<{ targetType: string; targetId: string }> = ({ targetType, targetId }) => {
const { data: event, isLoading: loading } = useEvent(targetType === 'event' ? targetId : '');
if (targetType === 'event') {
if (loading) return <Spin size="small" />;
if (event) {
const name = event.title || event.id;
return <Link to={`/events/${event.id}`}>{name}</Link>;
}
return <span>{targetId}</span>;
}
return <span>{targetId}</span>;
};
const typeColors: Record<string, string> = {
calendar: 'green',
event: 'blue',
review: 'purple',
};
const columns: ColumnsType<Review> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу отзыва">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/reviews/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Пользователь',
key: 'user',
ellipsis: true,
render: (_, record) => <UserCell userId={record.user_id} />,
},
{
title: 'Тип',
dataIndex: 'target_type',
key: 'target_type',
width: 100,
sorter: true,
render: (type: string) => (
<Tag color={typeColors[type] || 'default'}>{type}</Tag>
),
},
{
title: 'Цель',
key: 'target',
ellipsis: true,
render: (_, record) => <TargetCell targetType={record.target_type} targetId={record.target_id} />,
},
{
title: 'Оценка',
dataIndex: 'rating',
key: 'rating',
width: 140,
sorter: true,
render: (rating: number) => '⭐'.repeat(rating),
},
{
title: 'Комментарий',
dataIndex: 'comment',
key: 'comment',
ellipsis: true,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => (
<Tag color={status === 'visible' ? 'green' : status === 'hidden' ? 'orange' : 'red'}>
{status}
</Tag>
),
},
{
title: 'Лайки / Дизлайки',
key: 'likes',
width: 120,
render: (_, record) => {
const likes = isBadValue(record.likes) ? 0 : record.likes;
const dislikes = isBadValue(record.dislikes) ? 0 : record.dislikes;
return `${likes} / ${dislikes}`;
},
},
{
title: '',
key: 'actions',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
/>
</Tooltip>
),
},
];
return (
<div>
<h2>Отзывы</h2>
<Space style={{ marginBottom: 16 }}>
<Button onClick={() => openBulkStatusModal('hidden')} disabled={selectedRowKeys.length === 0}>
Скрыть выбранные
</Button>
<Button onClick={() => openBulkStatusModal('visible')} disabled={selectedRowKeys.length === 0}>
Показать выбранные
</Button>
<Button danger onClick={() => openBulkStatusModal('deleted')} disabled={selectedRowKeys.length === 0}>
Удалить выбранные
</Button>
</Space>
<Table
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys),
}}
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, reviewId: null })}
onOk={handleSaveEdit}
confirmLoading={updateReview.isPending}
destroyOnHidden
>
{loadingReview ? (
<Spin />
) : (
<Form form={editForm} layout="vertical" preserve={false}>
<Form.Item label="Оценка" name="rating">
<InputNumber min={1} max={5} />
</Form.Item>
<Form.Item label="Комментарий" name="comment">
<Input.TextArea rows={3} />
</Form.Item>
<Form.Item label="Статус" name="status">
<Select>
<Select.Option value="visible">visible</Select.Option>
<Select.Option value="hidden">hidden</Select.Option>
<Select.Option value="deleted">deleted</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Причина"
name="reason"
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const status = getFieldValue('status');
if ((status === 'hidden' || status === 'deleted') && (!value || value.trim() === '')) {
return Promise.reject(new Error('Укажите причину изменения статуса'));
}
return Promise.resolve();
},
}),
]}
>
<Input.TextArea rows={2} />
</Form.Item>
</Form>
)}
</Modal>
{/* Модальное окно для массового изменения с причиной */}
<Modal
title={`Изменить статус на «${bulkStatusModal.status}» для ${selectedRowKeys.length} отзывов`}
open={bulkStatusModal.open}
onCancel={() => setBulkStatusModal({ open: false, status: 'hidden' })}
onOk={handleBulkStatusSubmit}
confirmLoading={bulkUpdate.isPending}
destroyOnHidden
>
<Form form={bulkStatusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: bulkStatusModal.status === 'hidden' || bulkStatusModal.status === 'deleted',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Общая причина для выбранных отзывов" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ReviewListPage;
@@ -0,0 +1,263 @@
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 { Link } from 'react-router-dom';
import { useSubscriptions, useUpdateSubscription, useDeleteSubscription, useSubscription } from '../../hooks/useSubscriptions';
import { useUser } from '../../hooks/useUsers';
import { Subscription, SubscriptionListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
import dayjs from 'dayjs';
const SubscriptionListPage: React.FC = () => {
const [params, setParams] = useState<SubscriptionListParams>({ limit: 20, offset: 0 });
const { data, isLoading } = useSubscriptions(params);
const updateSubscription = useUpdateSubscription();
const deleteSubscription = useDeleteSubscription();
const [editModal, setEditModal] = useState<{ open: boolean; subscriptionId: string | null }>({
open: false,
subscriptionId: null,
});
const { data: editingSubscription, isLoading: loadingSubscription } = useSubscription(editModal.subscriptionId || '');
const [form] = Form.useForm();
// Заполняем форму с задержкой, когда данные загружены
useEffect(() => {
if (editingSubscription && editModal.open) {
setTimeout(() => {
form.setFieldsValue({
plan: editingSubscription.plan,
status: editingSubscription.status,
trial_used: editingSubscription.trial_used,
expires_at: editingSubscription.expires_at ? dayjs(editingSubscription.expires_at) : null,
});
}, 0);
}
}, [editingSubscription, editModal.open, form]);
const handleEdit = (sub: Subscription) => {
// Сбрасываем форму перед открытием
form.resetFields();
setEditModal({ open: true, subscriptionId: sub.id });
};
const handleSave = () => {
form.validateFields().then((values) => {
if (!editModal.subscriptionId) return;
const payload = Object.fromEntries(
Object.entries(values)
.filter(([_, v]) => v !== '' && v !== undefined && v !== null)
.map(([key, val]) => {
if (dayjs.isDayjs(val)) return [key, val.toISOString()];
return [key, val];
})
);
updateSubscription.mutate(
{ id: editModal.subscriptionId, data: payload },
{ onSuccess: () => setEditModal({ open: false, subscriptionId: null }) }
);
});
};
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить подписку?',
content: 'Это действие нельзя отменить.',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteSubscription.mutate(id),
});
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Subscription> | SorterResult<Subscription>[]
) => {
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) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
const isBadValue = (val: any) => val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
// Компонент для резолвинга пользователя
const UserCell: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading: loading } = useUser(userId);
if (loading) return <Spin size="small" />;
if (!user) return <span>{userId}</span>;
const name = !isBadValue(user.nickname) ? user.nickname : user.email;
return <Link to={`/users/${user.id}`}>{!isBadValue(name) ? name : user.id}</Link>;
};
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const planColors: Record<string, string> = {
trial: 'cyan',
monthly: 'blue',
quarterly: 'green',
biannual: 'purple',
annual: 'orange',
};
const columns: ColumnsType<Subscription> = [
{
title: 'Пользователь',
key: 'user',
ellipsis: true,
render: (_, record) => <UserCell userId={record.user_id} />,
},
{
title: 'План',
dataIndex: 'plan',
key: 'plan',
width: 100,
sorter: true,
render: (plan: string) => (
<Tag color={planColors[plan] || 'default'}>{plan}</Tag>
),
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => {
const color =
status === 'active' ? 'green' :
status === 'expired' ? 'orange' : 'red';
return <Tag color={color}>{status}</Tag>;
},
},
{
title: 'Пробный',
dataIndex: 'trial_used',
key: 'trial_used',
width: 100,
render: (v: boolean) => (v ? 'Да' : 'Нет'),
},
{ title: 'Начало', dataIndex: 'started_at', key: 'started_at', render: (val) => formatDate(val) },
{ title: 'Окончание', dataIndex: 'expires_at', key: 'expires_at', render: (val) => formatDate(val) },
{
title: 'Действия',
key: 'actions',
width: 100,
render: (_, record) => (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record)}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
/>
</Tooltip>
</Space>
),
},
];
return (
<div>
<h2>Подписки</h2>
<Space style={{ marginBottom: 16 }}>
<Select
allowClear
placeholder="План"
onChange={(val) => setParams({ ...params, plan: val, offset: 0 })}
style={{ width: 150 }}
>
<Select.Option value="monthly">monthly</Select.Option>
<Select.Option value="quarterly">quarterly</Select.Option>
<Select.Option value="biannual">biannual</Select.Option>
<Select.Option value="annual">annual</Select.Option>
<Select.Option value="trial">trial</Select.Option>
</Select>
<Select
allowClear
placeholder="Статус"
onChange={(val) => setParams({ ...params, status: val, offset: 0 })}
style={{ width: 150 }}
>
<Select.Option value="active">active</Select.Option>
<Select.Option value="expired">expired</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
</Select>
</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, subscriptionId: null })}
onOk={handleSave}
confirmLoading={updateSubscription.isPending}
destroyOnHidden
>
{loadingSubscription ? (
<Spin />
) : (
<Form form={form} layout="vertical" preserve={false}>
<Form.Item label="План" name="plan" rules={[{ required: true }]}>
<Select>
<Select.Option value="monthly">monthly</Select.Option>
<Select.Option value="quarterly">quarterly</Select.Option>
<Select.Option value="biannual">biannual</Select.Option>
<Select.Option value="annual">annual</Select.Option>
<Select.Option value="trial">trial</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Статус" name="status" rules={[{ required: true }]}>
<Select>
<Select.Option value="active">active</Select.Option>
<Select.Option value="expired">expired</Select.Option>
<Select.Option value="cancelled">cancelled</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Пробный период использован" name="trial_used">
<Select>
<Select.Option value={true}>Да</Select.Option>
<Select.Option value={false}>Нет</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Дата окончания" name="expires_at">
<DatePicker showTime format="YYYY-MM-DD HH:mm:ss" style={{ width: '100%' }} />
</Form.Item>
</Form>
)}
</Modal>
</div>
);
};
export default SubscriptionListPage;
+150
View File
@@ -0,0 +1,150 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag, Space, Form, Select, Input, message } from 'antd';
import { Link } from 'react-router-dom';
import { useTicket, useUpdateTicket } from '../../hooks/useTickets';
import { useUser } from '../../hooks/useUsers';
import { useAdmin, useAdmins } from '../../hooks/useAdmins';
import dayjs from 'dayjs';
const TicketDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: ticket, isLoading } = useTicket(id || '');
const updateTicket = useUpdateTicket();
const [form] = Form.useForm();
const { data: admins, isLoading: loadingAdmins } = useAdmins({ limit: 1000, offset: 0 });
const reporterId = ticket?.reporter_id;
const { data: reporter, isLoading: loadingReporter } = useUser(reporterId || '');
const assignedId = ticket?.assigned_to;
const { data: assignedAdmin, isLoading: loadingAssigned } = useAdmin(assignedId || '');
useEffect(() => {
if (ticket) {
form.setFieldsValue({
status: ticket.status,
assigned_to: !isBadValue(ticket.assigned_to) ? ticket.assigned_to : undefined,
resolution_note: !isBadValue(ticket.resolution_note) ? ticket.resolution_note : '',
});
}
}, [ticket, form]);
const handleSave = () => {
form.validateFields().then((values) => {
const payload = Object.fromEntries(
Object.entries(values).filter(([_, v]) => v !== '' && v !== undefined && v !== null)
);
updateTicket.mutate(
{ id: id!, data: payload },
{ onSuccess: () => navigate('/tickets') }
);
});
};
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const formatDate = (dateStr: string) => {
if (isBadValue(dateStr)) return '-';
const d = dayjs(dateStr);
return d.isValid() ? d.format('DD.MM.YYYY HH:mm') : dateStr;
};
const getUserLink = () => {
if (loadingReporter) return <Spin size="small" />;
if (!reporter) return reporterId || '-';
const name = reporter.nickname && !isBadValue(reporter.nickname)
? reporter.nickname
: reporter.email;
return <Link to={`/users/${reporter.id}`}>{name || reporter.id}</Link>;
};
if (isLoading) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (!ticket) return <p>Тикет не найден</p>;
return (
<Card title={`Тикет ${ticket.id}`}>
<Descriptions bordered column={1} size="small" style={{ marginBottom: 24 }}>
<Descriptions.Item label="ID">{ticket.id}</Descriptions.Item>
<Descriptions.Item label="Отправитель">{getUserLink()}</Descriptions.Item>
<Descriptions.Item label="Хеш ошибки">{ticket.error_hash}</Descriptions.Item>
<Descriptions.Item label="Сообщение">{ticket.error_message}</Descriptions.Item>
<Descriptions.Item label="Стектрейс">
<pre style={{ maxHeight: 200, overflow: 'auto' }}>
{isBadValue(ticket.stacktrace) ? '-' : ticket.stacktrace}
</pre>
</Descriptions.Item>
<Descriptions.Item label="Контекст">
{isBadValue(ticket.context) ? '-' : ticket.context}
</Descriptions.Item>
<Descriptions.Item label="Количество">{ticket.count}</Descriptions.Item>
<Descriptions.Item label="Первый раз">{formatDate(ticket.first_seen)}</Descriptions.Item>
<Descriptions.Item label="Последний раз">{formatDate(ticket.last_seen)}</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={
ticket.status === 'open' ? 'red' :
ticket.status === 'in_progress' ? 'blue' :
ticket.status === 'resolved' ? 'green' : 'default'
}>
{ticket.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Назначен">
{loadingAssigned ? <Spin size="small" /> : (
assignedAdmin ? (
<Link to={`/admins/${assignedAdmin.id}`}>
{!isBadValue(assignedAdmin.nickname) ? assignedAdmin.nickname : assignedAdmin.email || assignedAdmin.id}
</Link>
) : (isBadValue(ticket.assigned_to) ? '-' : ticket.assigned_to)
)}
</Descriptions.Item>
<Descriptions.Item label="Решение">
{isBadValue(ticket.resolution_note) ? '-' : ticket.resolution_note}
</Descriptions.Item>
</Descriptions>
<Form form={form} layout="vertical" onFinish={handleSave}>
<Form.Item label="Статус" name="status">
<Select>
<Select.Option value="open">open</Select.Option>
<Select.Option value="in_progress">in_progress</Select.Option>
<Select.Option value="resolved">resolved</Select.Option>
<Select.Option value="closed">closed</Select.Option>
</Select>
</Form.Item>
<Form.Item label="Назначить" name="assigned_to">
<Select
placeholder="Выберите администратора"
loading={loadingAdmins}
allowClear
showSearch
optionFilterProp="label"
>
{(admins || []).map((admin) => {
const label = `${admin.email}${admin.nickname && admin.nickname !== '-' ? ` ${admin.nickname}` : ''}`;
return (
<Select.Option key={admin.id} value={admin.id} label={label}>
{label}
</Select.Option>
);
})}
</Select>
</Form.Item>
<Form.Item label="Комментарий решения" name="resolution_note">
<Input.TextArea rows={3} />
</Form.Item>
<Space>
<Button type="primary" htmlType="submit" loading={updateTicket.isPending}>
Сохранить
</Button>
<Button onClick={() => navigate('/tickets')}>Назад к списку</Button>
</Space>
</Form>
</Card>
);
};
export default TicketDetailPage;
+158
View File
@@ -0,0 +1,158 @@
import React, { useState } from 'react';
import { Table, Button, Tag, Space, Popconfirm, Tooltip, Spin, Card, Col, Row, Statistic } from 'antd';
import { InfoCircleOutlined, DeleteOutlined, BugOutlined, ClockCircleOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { Link, useNavigate } from 'react-router-dom';
import { useTickets, useDeleteTicket, useTicketStats } from '../../hooks/useTickets';
import { useAdmin } from '../../hooks/useAdmins';
import { Ticket, TicketListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
const TicketListPage: React.FC = () => {
const navigate = useNavigate();
const [params, setParams] = useState<TicketListParams>({ limit: 20, offset: 0, sort: 'last_seen', order: 'desc' });
const { data, isLoading } = useTickets(params);
const deleteTicket = useDeleteTicket();
const { data: stats } = useTicketStats();
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
// Резолвер администратора для колонки "Назначен"
const AssignedCell: React.FC<{ adminId: string | null }> = ({ adminId }) => {
const { data: admin, isLoading: loading } = useAdmin(adminId || '');
if (!adminId || adminId === '-' || adminId === 'undefined') return <span>-</span>;
if (loading) return <Spin size="small" />;
if (!admin) return <span>{adminId}</span>;
const name = !isBadValue(admin.nickname) ? admin.nickname : admin.email;
return <Link to={`/admins/${admin.id}`}>{!isBadValue(name) ? name : admin.id}</Link>;
};
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<Ticket> | SorterResult<Ticket>[]
) => {
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) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
const columns: ColumnsType<Ticket> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу тикета">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/tickets/${record.id}`)}
/>
</Tooltip>
),
},
{
title: 'Ошибка',
dataIndex: 'error_message',
key: 'error_message',
ellipsis: true,
sorter: true,
render: (text: string) => isBadValue(text) ? '-' : text,
},
{
title: 'Статус',
dataIndex: 'status',
key: 'status',
width: 100,
sorter: true,
render: (status: string) => {
const color =
status === 'open' ? 'red' :
status === 'in_progress' ? 'blue' :
status === 'resolved' ? 'green' : 'default';
return <Tag color={color}>{status}</Tag>;
},
},
{
title: 'Назначен',
dataIndex: 'assigned_to',
key: 'assigned_to',
render: (assigned: string) => <AssignedCell adminId={assigned} />,
},
{ title: 'Повторов', dataIndex: 'count', key: 'count', width: 80, sorter: true },
{ title: 'Первый раз', dataIndex: 'first_seen', key: 'first_seen', sorter: true },
{ title: 'Последний', dataIndex: 'last_seen', key: 'last_seen', sorter: true },
{
title: 'Действия',
key: 'actions',
width: 80,
render: (_, record) => (
<Popconfirm
title="Удалить тикет?"
onConfirm={() => deleteTicket.mutate(record.id)}
>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
/>
</Tooltip>
</Popconfirm>
),
},
];
return (
<div>
<h2>Тикеты</h2>
{stats && (
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}>
<Card>
<Statistic title="Открыто" value={stats.open} prefix={<BugOutlined />} styles={{ content: { color: 'red' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic title="В работе" value={stats.in_progress} prefix={<ClockCircleOutlined />} styles={{ content: { color: 'blue' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic title="Решено" value={stats.resolved} prefix={<CheckCircleOutlined />} styles={{ content: { color: 'green' } }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card>
<Statistic title="Закрыто" value={stats.closed} prefix={<CloseCircleOutlined />} />
</Card>
</Col>
</Row>
)}
<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,
}}
/>
</div>
);
};
export default TicketListPage;
+56
View File
@@ -0,0 +1,56 @@
import React from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Card, Descriptions, Spin, Button, Tag } from 'antd';
import { useUser } from '../../hooks/useUsers';
import dayjs from 'dayjs';
const UserDetailPage: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: user, isLoading } = useUser(id || '');
const isBadValue = (val: any) =>
val === '-' || val === 'undefined' || val === '' || val === null || val === undefined;
const displayValue = (val: any) => (isBadValue(val) ? '-' : 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 (!user) return <p>Пользователь не найден</p>;
return (
<Card title={`Пользователь ${displayValue(user.nickname) !== '-' ? user.nickname : user.email || user.id}`}>
<Descriptions bordered column={1}>
<Descriptions.Item label="ID">{user.id}</Descriptions.Item>
<Descriptions.Item label="Email">{displayValue(user.email)}</Descriptions.Item>
<Descriptions.Item label="Ник">{displayValue(user.nickname)}</Descriptions.Item>
<Descriptions.Item label="Роль">
<Tag color={user.role === 'user' ? 'blue' : 'purple'}>{user.role}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Статус">
<Tag color={user.status === 'active' ? 'green' : user.status === 'frozen' ? 'orange' : 'red'}>
{user.status}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="Причина">{displayValue(user.reason)}</Descriptions.Item>
<Descriptions.Item label="Телефон">{displayValue(user.phone)}</Descriptions.Item>
<Descriptions.Item label="Язык">{displayValue(user.language)}</Descriptions.Item>
<Descriptions.Item label="Часовой пояс">{displayValue(user.timezone)}</Descriptions.Item>
<Descriptions.Item label="Аватар URL">{displayValue(user.avatar_url)}</Descriptions.Item>
<Descriptions.Item label="Социальные ссылки">{displayValue(user.social_links)}</Descriptions.Item>
<Descriptions.Item label="Настройки">{displayValue(user.preferences)}</Descriptions.Item>
<Descriptions.Item label="Последний вход">{formatDate(user.last_login)}</Descriptions.Item>
<Descriptions.Item label="Создано">{formatDate(user.created_at)}</Descriptions.Item>
<Descriptions.Item label="Обновлено">{formatDate(user.updated_at)}</Descriptions.Item>
</Descriptions>
<Button style={{ marginTop: 16 }} onClick={() => navigate('/users')}>Назад к списку</Button>
</Card>
);
};
export default UserDetailPage;
+354
View File
@@ -0,0 +1,354 @@
import React, { useState, useEffect, useRef } from 'react';
import { Table, Button, Tag, Space, Modal, Form, Select, message, Spin, Input, Tooltip } from 'antd';
import { InfoCircleOutlined, EditOutlined, LockOutlined, UnlockOutlined, DeleteOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router-dom';
import { useUsers, useUpdateUser, useDeleteUser, useUser } from '../../hooks/useUsers';
import { User, UserListParams } from '../../types/api';
import type { ColumnsType, SorterResult } from 'antd/es/table/interface';
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 updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
// Редактирование
const [editModal, setEditModal] = useState<{ open: boolean; userId: string | null }>({
open: false,
userId: null,
});
const { data: editingUser, isLoading: loadingUser } = useUser(editModal.userId || '');
const [editForm] = Form.useForm();
const [editHasErrors, setEditHasErrors] = useState(true);
const originalUserRef = useRef<User | null>(null);
// Изменение статуса
const [statusModal, setStatusModal] = useState<{
open: boolean;
userId: string | null;
newStatus: string;
currentReason: string;
}>({
open: false,
userId: null,
newStatus: 'active',
currentReason: '',
});
const [statusForm] = Form.useForm();
// ==================== Редактирование ====================
const handleEdit = (id: string) => {
setEditModal({ open: true, userId: id });
};
const handleSaveEdit = () => {
editForm.validateFields().then((values) => {
if (!editModal.userId || !originalUserRef.current) return;
const original = originalUserRef.current;
const cleanedValues: Record<string, any> = {};
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 any)[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 any)[key];
return JSON.stringify(v) !== JSON.stringify(origVal === '-' ? undefined : origVal);
})
);
updateUser.mutate(
{ id: editModal.userId, data: payload },
{ onSuccess: () => setEditModal({ open: false, userId: null }) }
);
});
};
useEffect(() => {
if (editingUser && editModal.open) {
originalUserRef.current = editingUser;
const clean = (val: any) => (val === '-' || val === 'undefined' ? undefined : val);
setTimeout(() => {
editForm.setFieldsValue({
email: clean(editingUser.email),
nickname: clean(editingUser.nickname),
role: clean(editingUser.role),
status: clean(editingUser.status),
reason: clean(editingUser.reason),
});
validateEditForm();
}, 0);
}
}, [editingUser, editModal.open, editForm]);
const validateEditForm = () => {
const role = editForm.getFieldValue('role');
const status = editForm.getFieldValue('status');
const hasErrors = !role || !status;
setEditHasErrors(hasErrors);
};
// ==================== Изменение статуса ====================
const handleStatusChange = (user: User) => {
if (user.status === 'deleted') return;
const newStatus = user.status === 'active' ? 'frozen' : 'active';
const currentReason = user.reason && user.reason !== '-' ? user.reason : '';
setStatusModal({
open: true,
userId: user.id,
newStatus,
currentReason,
});
};
useEffect(() => {
if (statusModal.open) {
setTimeout(() => {
statusForm.setFieldsValue({ reason: statusModal.currentReason });
}, 0);
}
}, [statusModal.open, statusModal.currentReason, statusForm]);
const handleStatusSave = () => {
statusForm.validateFields().then((values) => {
if (!statusModal.userId) return;
const payload: any = { status: statusModal.newStatus };
if (values.reason && values.reason.trim() !== '') {
payload.reason = values.reason;
}
updateUser.mutate(
{ id: statusModal.userId, data: payload },
{ onSuccess: () => setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' }) }
);
});
};
// ==================== Удаление ====================
const handleDelete = (id: string) => {
Modal.confirm({
title: 'Удалить пользователя?',
content: 'Это действие нельзя отменить. При необходимости предварительно укажите причину через редактирование.',
okText: 'Удалить',
okType: 'danger',
cancelText: 'Отмена',
onOk: () => deleteUser.mutate(id),
});
};
// ==================== Таблица ====================
const handleTableChange = (
pagination: any,
filters: any,
sorter: SorterResult<User> | SorterResult<User>[]
) => {
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) * pagination.pageSize) || 0,
limit: pagination.pageSize || prev.limit,
}));
};
const columns: ColumnsType<User> = [
{
title: <InfoCircleOutlined />,
key: 'detail',
width: 48,
align: 'center',
render: (_, record) => (
<Tooltip title="Открыть страницу пользователя">
<Button
icon={<InfoCircleOutlined />}
size="small"
type="link"
onClick={() => navigate(`/users/${record.id}`)}
/>
</Tooltip>
),
},
{ title: 'Email', dataIndex: 'email', key: 'email', sorter: true, ellipsis: true },
{ title: 'Ник', dataIndex: 'nickname', key: 'nickname', sorter: true, ellipsis: true },
{
title: 'Роль',
dataIndex: 'role',
key: 'role',
width: 100,
sorter: true,
render: (role: string) => {
const color = role === 'user' ? 'blue' : role === 'bot' ? 'purple' : 'default';
return <Tag color={color}>{role}</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: 'last_login', key: 'last_login', sorter: true },
{
title: 'Действия',
key: 'actions',
width: 120,
render: (_, record) => {
const isDeleted = record.status === 'deleted';
return (
<Space>
<Tooltip title="Редактировать">
<Button
icon={<EditOutlined />}
size="small"
onClick={() => handleEdit(record.id)}
/>
</Tooltip>
<Tooltip title={record.status === 'active' ? 'Заморозить' : 'Разморозить'}>
<Button
icon={record.status === 'active' ? <LockOutlined /> : <UnlockOutlined />}
size="small"
onClick={() => handleStatusChange(record)}
disabled={isDeleted}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
icon={<DeleteOutlined />}
size="small"
danger
onClick={() => handleDelete(record.id)}
disabled={isDeleted}
/>
</Tooltip>
</Space>
);
},
},
];
return (
<div>
<h2>Пользователи</h2>
<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, userId: null })}
onOk={handleSaveEdit}
confirmLoading={updateUser.isPending}
destroyOnHidden
okButtonProps={{ disabled: editHasErrors }}
>
{loadingUser ? (
<div style={{ textAlign: 'center', padding: 24 }}><Spin /></div>
) : (
<Form form={editForm} layout="vertical" preserve={false} onValuesChange={validateEditForm}>
<Form.Item label="Email" name="email">
<Input />
</Form.Item>
<Form.Item label="Ник" name="nickname">
<Input />
</Form.Item>
<Form.Item
label="Роль"
name="role"
rules={[{ required: true, message: 'Выберите роль' }]}
>
<Select placeholder="Выберите роль">
<Select.Option value="user">user</Select.Option>
<Select.Option value="bot">bot</Select.Option>
</Select>
</Form.Item>
<Form.Item
label="Статус"
name="status"
rules={[{ required: true, message: 'Выберите статус' }]}
>
<Select placeholder="Выберите статус">
<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="reason"
rules={[
({ getFieldValue }) => ({
validator(_, value) {
const status = getFieldValue('status');
if (status && status !== 'active' && (!value || value.trim() === '')) {
return Promise.reject(new Error('Укажите причину изменения статуса'));
}
return Promise.resolve();
},
}),
]}
>
<Input.TextArea rows={3} />
</Form.Item>
</Form>
)}
</Modal>
{/* Модальное окно изменения статуса */}
<Modal
title={`Изменить статус на «${statusModal.newStatus}»`}
open={statusModal.open}
onCancel={() => setStatusModal({ open: false, userId: null, newStatus: 'active', currentReason: '' })}
onOk={handleStatusSave}
confirmLoading={updateUser.isPending}
destroyOnHidden
>
<Form form={statusForm} layout="vertical" preserve={false}>
<Form.Item
label="Причина"
name="reason"
rules={[
{
required: statusModal.newStatus !== 'active',
message: 'Укажите причину',
},
]}
>
<Input.TextArea rows={3} placeholder="Введите причину изменения статуса" />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UserListPage;
+70
View File
@@ -0,0 +1,70 @@
import { create } from 'zustand';
import { Admin } from '../types/api';
import { authApi } from '../api/authApi';
import { ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY } from '../utils/constants';
interface AuthState {
user: Admin | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
isInitialized: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<void>;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
accessToken: localStorage.getItem(ACCESS_TOKEN_KEY),
refreshToken: localStorage.getItem(REFRESH_TOKEN_KEY),
isAuthenticated: false,
isInitialized: false,
login: async (email: string, password: string) => {
const { token, refresh_token, user } = await authApi.login(email, password);
localStorage.setItem(ACCESS_TOKEN_KEY, token);
localStorage.setItem(REFRESH_TOKEN_KEY, refresh_token);
set({
accessToken: token,
refreshToken: refresh_token,
user,
isAuthenticated: true,
isInitialized: true,
});
},
logout: async () => {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isInitialized: true,
});
},
checkAuth: async () => {
const token = localStorage.getItem(ACCESS_TOKEN_KEY);
if (!token) {
set({ isInitialized: true });
return;
}
try {
const user = await authApi.getMe();
set({ user, isAuthenticated: true, isInitialized: true });
} catch {
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
isInitialized: true,
});
}
},
}));
+299
View File
@@ -0,0 +1,299 @@
// ===================== Event (Событие) =====================
export interface Event {
id: string;
calendar_id: string;
title: string;
description: string;
event_type: 'single' | 'recurring';
start_time: string; // ISO8601
duration: number;
recurrence: object | null;
master_id: string | null;
is_instance: boolean;
specialist_id: string | null;
location: object | null;
tags: string[];
capacity: number | null;
online_link: string | null;
status: 'active' | 'cancelled' | 'completed';
reason: string | null;
rating_avg: number;
rating_count: number;
attachments: string[] | null;
edit_history: object[] | null;
created_at: string; // ISO8601
updated_at: string; // ISO8601
}
export interface EventListParams {
from?: string;
to?: string;
status?: 'active' | 'cancelled' | 'completed';
calendar_id?: string;
title?: string;
q?: string;
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
// ===================== User (Пользователь) =====================
export interface User {
id: string;
email: string;
role: 'user' | 'bot';
status: 'active' | 'frozen' | 'deleted';
reason: string | null;
nickname: string | null;
avatar_url: string | null;
timezone: string | null;
language: string | null;
social_links: string[] | null;
phone: string | null;
preferences: object | null;
last_login: string; // ISO8601
created_at: string; // ISO8601
updated_at: string; // ISO8601
}
export interface UserListParams {
role?: 'user' | 'bot';
status?: 'active' | 'frozen' | 'deleted';
q?: string;
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
// ===================== Report (Жалоба) =====================
export interface Report {
id: string;
reporter_id: string;
target_type: 'calendar' | 'event' | 'review';
target_id: string;
reason: string;
status: 'pending' | 'reviewed' | 'dismissed';
created_at: string;
resolved_at: string | null;
resolved_by: string | null;
}
export interface ReportListParams {
status?: 'pending' | 'reviewed' | 'dismissed';
target_type?: 'calendar' | 'event' | 'review';
q?: string;
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
// ===================== Review (Отзыв) =====================
export interface Review {
id: string;
user_id: string;
target_type: 'calendar' | 'event';
target_id: string;
rating: number; // 1-5
comment: string;
status: 'visible' | 'hidden' | 'deleted';
reason: string | null;
likes: number;
dislikes: number;
created_at: string;
updated_at: string;
}
export interface ReviewListParams {
target_type?: 'calendar' | 'event';
target_id?: string;
user_id?: string;
status?: 'visible' | 'hidden' | 'deleted';
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
// ===================== Banned Word (Бан-слово) =====================
export interface BannedWord {
id: string;
word: string;
added_by: string | null;
added_at: string | null;
}
// ===================== Ticket (Тикет баг-трекера) =====================
export interface Ticket {
id: string;
reporter_id: string;
error_hash: string;
error_message: string;
stacktrace: string;
context: string;
count: number;
first_seen: string;
last_seen: string;
status: 'open' | 'in_progress' | 'resolved' | 'closed';
assigned_to: string | null;
resolution_note: string | null;
}
export interface TicketListParams {
status?: 'open' | 'in_progress' | 'resolved' | 'closed';
assigned_to?: string;
q?: string;
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
export interface TicketStats {
open: number;
in_progress: number;
resolved: number;
closed: number;
total: number;
}
// ===================== Subscription (Подписка) =====================
export interface Subscription {
id: string;
user_id: string;
plan: 'monthly' | 'quarterly' | 'biannual' | 'annual';
status: 'active' | 'expired' | 'cancelled';
trial_used: boolean;
started_at: string;
expires_at: string;
created_at: string;
updated_at: string;
}
export interface SubscriptionListParams {
plan?: 'monthly' | 'quarterly' | 'biannual' | 'annual';
status?: 'active' | 'expired' | 'cancelled';
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
// ===================== Admin (Администратор) =====================
export interface Admin {
id: string;
email: string;
role: 'superadmin' | 'admin' | 'moderator' | 'support';
status: 'active' | 'blocked';
nickname: string | null;
avatar_url: string | null;
timezone: string | null;
language: string | null;
phone: string | null;
preferences: object | null;
last_login: string;
created_at: string;
updated_at: string;
}
export interface AdminListParams {
role?: 'superadmin' | 'admin' | 'moderator' | 'support';
status?: 'active' | 'blocked';
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
// ===================== Audit (Запись аудита) =====================
export interface AuditRecord {
id: string;
admin_id: string;
email: string;
role: string;
action: string;
entity_type: string;
entity_id: string;
timestamp: string;
ip: string;
reason: string | null;
}
export interface AuditListParams {
admin_id?: string;
action?: string;
date_from?: string;
date_to?: string;
limit?: number;
offset?: number;
sort?: string;
order?: 'asc' | 'desc';
}
// ===================== Dashboard Stats =====================
export interface DashboardStats {
users_total: number;
events_total: number;
reviews_total: number;
calendars_total: number;
reports_total: number;
tickets_total: number;
tickets_open: number;
avg_ticket_resolution_h: number;
admin_activity: AdminActivity[];
events_by_day: DayCount[];
registrations_by_day: DayCount[];
}
export interface AdminActivity {
admin_id: string;
email: string;
role: string;
last_login: string;
nickname: string;
actions: number;
}
export interface DayCount {
date: string;
count: number;
}
// ===================== Moderation =====================
export type TargetType = 'calendar' | 'event' | 'review' | 'user';
export type ModerationAction = {
calendar: 'freeze' | 'unfreeze';
event: 'freeze' | 'unfreeze';
review: 'hide' | 'unhide';
user: 'block' | 'unblock';
};
export interface ModerationPayload {
action: string;
reason?: string;
}
// ===================== Auth =====================
export interface LoginRequest {
email: string;
password: string;
}
export interface LoginResponse {
access_token: string;
refresh_token: string;
}
export interface RefreshResponse {
access_token: string;
refresh_token: string;
}
// ===================== Pagination =====================
export interface PaginatedResponse<T> {
data: T[];
total: number;
}
+2
View File
@@ -0,0 +1,2 @@
export const ACCESS_TOKEN_KEY = 'access_token';
export const REFRESH_TOKEN_KEY = 'refresh_token';
+48
View File
@@ -0,0 +1,48 @@
/**
* Заменяет все "некрасивые" значения в объекте или массиве на дефис.
* - null
* - "undefined" (строка)
* - undefined
* - пустая строка ""
*/
import dayjs from 'dayjs';
const isDateKey = (key: string): boolean => {
// Покрываем: _at, _date, _time, _seen, timestamp, last_login, date, time и т.д.
const datePatterns = /_(at|date|time|seen)$|^(date|time|timestamp|last_login)$/;
return datePatterns.test(key);
};
const formatDateValue = (value: unknown): string => {
if (typeof value !== 'string' || value === '-' || value === '') return value as string;
const d = dayjs(value);
if (!d.isValid()) return value;
if (value.includes('T') || value.length > 10) {
return d.format('DD.MM.YYYY HH:mm');
}
return d.format('DD.MM.YYYY');
};
export const normalizeData = <T>(data: T): T => {
if (data === null || data === undefined || data === 'undefined' || data === '') {
return '-' as unknown as T;
}
if (Array.isArray(data)) {
return data.map(item => normalizeData(item)) as unknown as T;
}
if (typeof data === 'object' && data !== null) {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(data)) {
let normalized = normalizeData(value);
if (isDateKey(key) && typeof normalized === 'string' && normalized !== '-') {
normalized = formatDateValue(normalized);
}
result[key] = normalized;
}
return result as T;
}
return data;
};