Разработка админ-панели EventHubFrontAdmin v1.0 #1
This commit is contained in:
+184
@@ -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
@@ -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;
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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)}`);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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}`);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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 |
@@ -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 |
@@ -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;
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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('Ошибка удаления'),
|
||||
});
|
||||
};
|
||||
@@ -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 };
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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('Ошибка удаления'),
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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('Ошибка удаления'),
|
||||
});
|
||||
};
|
||||
@@ -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('Ошибка обновления'),
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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('Ошибка удаления'),
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const ACCESS_TOKEN_KEY = 'access_token';
|
||||
export const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user