diff --git a/admin/public/config.js b/admin/public/config.js index fca0bc98..dd42e5c0 100644 --- a/admin/public/config.js +++ b/admin/public/config.js @@ -1,4 +1,5 @@ window.api = { // API_URL: "https://gopadel.dev.shamps.dev", - API_URL: 'http://localhost:8000' + API_URL: "https://gopadel.leenky.ru", + // API_URL: 'http://localhost:8888' }; diff --git a/admin/src/api/clubs.ts b/admin/src/api/clubs.ts index 8755330e..908d53b3 100644 --- a/admin/src/api/clubs.ts +++ b/admin/src/api/clubs.ts @@ -1,45 +1,43 @@ import { api } from './api'; +import type { Club, CreateClub, PatchClub } from '../shared/types'; -export interface Club { - id: string; - name: string; - description?: string; - createdAt: string; - userCount?: number; -} - -export interface CreateClub { - id: string; - name: string; - description?: string; -} +// Реэкспорт для удобства использования в компонентах +export type { Club, CreateClub, PatchClub }; -export interface PatchClub { +// Модель фильтра клубов +export interface FilterClub { id?: string; name?: string; - description?: string; + isPrivate?: boolean; + url?: string; } export const clubsApi = { - // Получить все клубы + // Получение списка клубов с фильтрацией + filter: async (filter: FilterClub): Promise => { + const response = await api.post('/admin/clubs/filter', filter); + return response.data; + }, + + // Получение всех клубов getAll: async (): Promise => { - const response = await api.get('/admin/clubs'); + const response = await api.get('/admin/clubs'); return response.data; }, - // Создать новый клуб + // Создание клуба create: async (data: CreateClub): Promise => { - const response = await api.post('/admin/clubs', data); + const response = await api.post('/admin/clubs', data); return response.data; }, - // Обновить клуб + // Обновление клуба patch: async (id: string, data: PatchClub): Promise => { - const response = await api.patch(`/admin/clubs/${id}`, data); + const response = await api.patch(`/admin/clubs/${id}`, data); return response.data; }, - // Удалить клуб + // Удаление клуба delete: async (id: string): Promise => { await api.delete(`/admin/clubs/${id}`); }, diff --git a/admin/src/api/courts.ts b/admin/src/api/courts.ts index 866ad97c..de80c562 100644 --- a/admin/src/api/courts.ts +++ b/admin/src/api/courts.ts @@ -1,20 +1,8 @@ import { api } from './api'; +import type { Court, CreateCourt, PatchCourt } from '../shared/types'; -export interface Court { - id: string; - name: string; - address: string; -} - -export interface CreateCourt { - name: string; - address: string; -} - -export interface PatchCourt { - name?: string; - address?: string; -} +// Реэкспорт для удобства использования в компонентах +export type { Court, CreateCourt, PatchCourt }; export const courtsApi = { async getAll(): Promise { diff --git a/admin/src/api/events.ts b/admin/src/api/events.ts new file mode 100644 index 00000000..c2945ba6 --- /dev/null +++ b/admin/src/api/events.ts @@ -0,0 +1,138 @@ +import { api } from './api'; +import type { + Event, + EventType, + EventStatus, + User, + Court, + RegistrationStatus +} from '../shared/types'; + +// Реэкспорт для удобства использования в компонентах +export type { Event, EventType, EventStatus, User, Court, RegistrationStatus }; + +// Модель для создания события +export interface CreateEvent { + name: string; + description?: string; + startTime: string; + endTime?: string; + rankMin: number; + rankMax: number; + price: number; + maxUsers: number; + type: EventType; + courtId: string; + clubId?: string; + organizerId?: string; + data?: Record; +} + +// Модель для обновления события +export interface PatchEvent { + name?: string; + description?: string; + startTime?: string; + endTime?: string; + rankMin?: number; + rankMax?: number; + price?: number; + maxUsers?: number; + status?: EventStatus; + type?: EventType; + courtId?: string; + organizerId?: string; + clubId?: string; + data?: Record; +} + +// Модель для обновления события (админ) +export interface AdminPatchEvent { + name?: string; + description?: string; + startTime?: string; + endTime?: string; + rankMin?: number; + rankMax?: number; + price?: number; + maxUsers?: number; + status?: EventStatus; + type?: EventType; + courtId?: string; + organizerId?: string; + clubId?: string; + data?: Record; +} + +// Фильтр для событий +export interface FilterEvent { + id?: string; + name?: string; + status?: EventStatus; + type?: EventType; + notFull?: boolean; + notCompleted?: boolean; + organizerId?: string; + clubId?: string; + filterByUserClubs?: string; +} + +// Фильтр для событий (админ) +export interface AdminFilterEvent { + id?: string; + name?: string; + status?: EventStatus; + type?: EventType; + organizerId?: string; + organizerFirstName?: string; + organizerTelegramId?: number; + clubId?: string; + clubName?: string; +} + +// Модель регистрации для событий +export interface EventRegistration { + userId: string; + eventId: string; + status: RegistrationStatus; + createdAt: string; + updatedAt: string; + user?: User; +} + +export const eventsApi = { + // Получение списка событий с фильтрацией + filter: async (filter: AdminFilterEvent): Promise => { + const response = await api.post('/admin/events/filter', filter); + return response.data; + }, + + // Получение всех событий + getAll: async (): Promise => { + const response = await api.post('/admin/events/filter', {}); + return response.data; + }, + + // Создание события + create: async (data: CreateEvent): Promise => { + const response = await api.post('/admin/events', data); + return response.data; + }, + + // Обновление события + patch: async (id: string, data: AdminPatchEvent): Promise => { + const response = await api.patch(`/admin/events/${id}`, data); + return response.data; + }, + + // Удаление события + delete: async (id: string): Promise => { + await api.delete(`/admin/events/${id}`); + }, + + // Обновление статуса события + updateStatus: async (id: string, status: EventStatus): Promise => { + const response = await api.patch(`/admin/events/${id}`, { status }); + return response.data; + }, +}; \ No newline at end of file diff --git a/admin/src/api/registrations.ts b/admin/src/api/registrations.ts index 4897e3be..c17e9f20 100644 --- a/admin/src/api/registrations.ts +++ b/admin/src/api/registrations.ts @@ -1,163 +1,152 @@ import { api } from './api'; - -export interface User { - id: string; - firstName: string; - lastName: string; - telegramUsername: string; - telegramId: number; - rank: number; - city: string; +import type { + User, + Event, + RegistrationStatus, + Registration, + Payment, + EventType +} from '../shared/types'; + +// Реэкспорт для удобства использования в компонентах +export type { User, Event, RegistrationStatus, Registration, Payment, EventType }; + +// Расширенная модель регистрации с платежами (из swagger.json) +export interface RegistrationWithPayments extends Registration { + payments?: Payment[]; } -export interface Tournament { +// Модель опции события для выпадающих списков +export interface EventOption { id: string; name: string; + type: EventType; startTime: string; - endTime: string; - price: number; - rankMin: number; - rankMax: number; - maxUsers: number; - description: string; - court: { - id: string; - name: string; - address: string; - }; - tournamentType: string; - organizator: User; -} - -export type RegistrationStatus = 'PENDING' | 'ACTIVE' | 'CANCELED' | 'CANCELED_BY_USER'; - -export interface RegistrationWithPayments { - id: string; - userId: string; - tournamentId: string; - date: string; - status: RegistrationStatus; - user?: { - id: string; - telegramId: number; - telegramUsername?: string; - firstName: string; - lastName: string; - avatar?: string; - rank: number; - city?: string; - isRegistered: boolean; - }; - tournament?: { - id: string; - name: string; - startTime: string; - endTime?: string; - price: number; - rankMin: number; - rankMax: number; - maxUsers: number; - description?: string; - tournamentType: string; - court: { - id: string; - name: string; - address: string; - }; - organizator: { - id: string; - telegramId: number; - telegramUsername?: string; - firstName: string; - lastName: string; - avatar?: string; - rank: number; - }; - }; - payments?: Payment[]; -} - -export interface Payment { - id: string; - paymentId: string; - date: string; - amount: number; - status: string; - paymentLink: string; - confirmationToken: string; - registrationId: string; -} - -export interface AdminFilterRegistration { - id?: string; - userId?: string; - tournamentId?: string; - status?: RegistrationStatus; - userTelegramId?: number; - userTelegramUsername?: string; - userFirstName?: string; - tournamentName?: string; + endTime?: string; } +// Модель опции турнира для выпадающих списков export interface TournamentOption { id: string; name: string; - startTime?: string; + startTime: string; endTime?: string; } +// Модель опции пользователя для выпадающих списков export interface UserOption { id: string; + telegramId: number; + telegramUsername?: string; firstName: string; lastName: string; - telegramUsername?: string; - telegramId: number; + avatar?: string; + rank: number; } -export interface FilterUser { - id?: string; - telegramId?: number; +// Модель запроса поиска пользователей +export interface UserSearchRequest { + telegramUsername?: string; firstName?: string; - lastName?: string; + telegramId?: number; } -export interface UserSearchRequest { - telegramUsername: string; +// Модель фильтра админских регистраций (из swagger.json) +export interface AdminFilterRegistration { + eventId?: string; + eventName?: string; + status?: RegistrationStatus; + userFirstName?: string; + userId?: string; + userTelegramId?: number; + userTelegramUsername?: string; +} + +// Модель для обновления статуса регистрации (из swagger.json) +export interface RegistrationStatusUpdate { + status: RegistrationStatus; } +// Статусы для игр +export const gameStatusOptions = [ + { value: 'INVITED' as RegistrationStatus, label: 'Приглашён', color: 'text-blue-300', bgColor: 'bg-blue-900/30' }, + { value: 'CONFIRMED' as RegistrationStatus, label: 'Подтверждено', color: 'text-green-300', bgColor: 'bg-green-900/30' }, + { value: 'CANCELLED' as RegistrationStatus, label: 'Отменена', color: 'text-red-300', bgColor: 'bg-red-900/30' }, + { value: 'LEFT' as RegistrationStatus, label: 'Покинул', color: 'text-gray-300', bgColor: 'bg-gray-900/30' }, +]; + +// Статусы для турниров +export const tournamentStatusOptions = [ + { value: 'PENDING' as RegistrationStatus, label: 'Ожидание', color: 'text-yellow-300', bgColor: 'bg-yellow-900/30' }, + { value: 'CANCELLED_BEFORE_PAYMENT' as RegistrationStatus, label: 'Отменена до оплаты', color: 'text-red-300', bgColor: 'bg-red-900/30' }, + { value: 'CONFIRMED' as RegistrationStatus, label: 'Подтверждено', color: 'text-green-300', bgColor: 'bg-green-900/30' }, + { value: 'REFUNDED' as RegistrationStatus, label: 'Возврат', color: 'text-purple-300', bgColor: 'bg-purple-900/30' }, +]; + +// Все статусы для отображения в фильтрах +export const allStatusOptions = [ + { value: 'PENDING' as RegistrationStatus, label: 'Ожидание', color: 'text-yellow-300', bgColor: 'bg-yellow-900/30' }, + { value: 'INVITED' as RegistrationStatus, label: 'Приглашён', color: 'text-blue-300', bgColor: 'bg-blue-900/30' }, + { value: 'CONFIRMED' as RegistrationStatus, label: 'Подтверждено', color: 'text-green-300', bgColor: 'bg-green-900/30' }, + { value: 'CANCELLED_BEFORE_PAYMENT' as RegistrationStatus, label: 'Отменена до оплаты', color: 'text-red-300', bgColor: 'bg-red-900/30' }, + { value: 'REFUNDED' as RegistrationStatus, label: 'Возврат', color: 'text-purple-300', bgColor: 'bg-purple-900/30' }, + { value: 'CANCELLED' as RegistrationStatus, label: 'Отменена', color: 'text-red-300', bgColor: 'bg-red-900/30' }, + { value: 'LEFT' as RegistrationStatus, label: 'Покинул', color: 'text-gray-300', bgColor: 'bg-gray-900/30' }, +]; + +export const paymentStatusOptions = [ + { value: 'pending', label: 'Ожидает', color: 'text-yellow-400', icon: 'Clock' }, + { value: 'succeeded', label: 'Успешно', color: 'text-green-400', icon: 'CheckCircle' }, + { value: 'canceled', label: 'Отменено', color: 'text-red-400', icon: 'XCircle' }, + { value: 'refunded', label: 'Возврат', color: 'text-purple-400', icon: 'RotateCcw' }, +]; + +// API согласно swagger.json export const registrationsApi = { + // Получение регистраций с фильтрацией (из swagger.json) filter: async (filter: AdminFilterRegistration): Promise => { const response = await api.post('/admin/registrations/filter', filter); return response.data; }, - getAll: async (): Promise => { - const response = await api.post('/admin/registrations/filter', {}); - return response.data; - }, - - getById: async (id: string): Promise => { - const response = await api.get(`/admin/registrations/${id}`); + // Обновление статуса регистрации (из swagger.json) + updateStatus: async (userId: string, eventId: string, statusUpdate: RegistrationStatusUpdate): Promise => { + const response = await api.patch(`/admin/registrations/${userId}/${eventId}/status`, statusUpdate); return response.data; }, - updateStatus: async (id: string, status: RegistrationStatus): Promise => { - const response = await api.patch(`/admin/registrations/${id}/status`, { status }); - return response.data; + getEventOptions: async (): Promise => { + const response = await api.post('/admin/events/filter', {}); + return response.data.map((event) => ({ + id: event.id, + name: event.name, + type: event.type, + startTime: event.startTime, + endTime: event.endTime, + })); }, getTournamentOptions: async (): Promise => { - const response = await api.get('/admin/registrations/tournaments'); - return response.data; + // Получаем все события (игры и турниры) + const response = await api.post('/admin/events/filter', {}); + return response.data.map((event) => ({ + id: event.id, + name: event.name, + startTime: event.startTime, + endTime: event.endTime, + })); }, + // Поиск пользователей через admin/users API getUserOptions: async (telegramUsername: string): Promise => { - const response = await api.post('/admin/registrations/users', { telegramUsername }); + const response = await api.post('/admin/users/filter', { + telegramUsername: telegramUsername + }); return response.data; }, searchUsers: async (request: UserSearchRequest): Promise => { - const response = await api.post('/admin/registrations/users', request); + const response = await api.post('/admin/users/filter', request); return response.data; }, }; \ No newline at end of file diff --git a/admin/src/api/waitlist.ts b/admin/src/api/waitlist.ts index 7a0c4f85..536e4f86 100644 --- a/admin/src/api/waitlist.ts +++ b/admin/src/api/waitlist.ts @@ -1,28 +1,23 @@ import { api } from './api'; +import type { WaitlistUser } from '../shared/types'; -export interface WaitlistUser { - user: { - id: string; - telegramId: string; - telegramUsername?: string; - firstName: string; - lastName: string; - avatar?: string; - bio?: string; - rank?: number; - city?: string; - birthDate?: string; - playingPosition?: string; - padelProfiles?: string; - isRegistered?: boolean; - }; - date: string; -} +// Реэкспорт для удобства использования в компонентах +export type { WaitlistUser }; export const waitlistApi = { - // Получить список ожидания турнира - getTournamentWaitlist: async (tournamentId: string): Promise => { - const response = await api.get(`/admin/waitlist/tournament/${tournamentId}`); + // Получить список ожидания для события + getEventWaitlist: async (eventId: string): Promise => { + const response = await api.get(`/admin/events/${eventId}/waitlist`); return response.data; }, + + // Добавить пользователя в список ожидания (для обычных пользователей) + addToWaitlist: async (eventId: string): Promise => { + await api.post(`/events/${eventId}/waitlist`); + }, + + // Удалить пользователя из списка ожидания (для обычных пользователей) + removeFromWaitlist: async (eventId: string): Promise => { + await api.delete(`/events/${eventId}/waitlist`); + }, }; \ No newline at end of file diff --git a/admin/src/components/CourtsPage.tsx b/admin/src/components/CourtsPage.tsx index 63020da2..0784229a 100644 --- a/admin/src/components/CourtsPage.tsx +++ b/admin/src/components/CourtsPage.tsx @@ -137,7 +137,7 @@ export const CourtsPage: React.FC = () => { // Обработчик изменения полей const handleInputChange = (field: string, value: string) => { - setFormData(prev => ({ + setFormData((prev: any) => ({ ...prev, [field]: value, })); diff --git a/admin/src/components/Dashboard.tsx b/admin/src/components/Dashboard.tsx index ce7bdae2..b66e9d49 100644 --- a/admin/src/components/Dashboard.tsx +++ b/admin/src/components/Dashboard.tsx @@ -14,7 +14,8 @@ import { Home, Menu, X, - Building2 + Building2, + Gamepad2 } from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import { UsersPage } from './UsersPage'; @@ -24,6 +25,7 @@ import { CourtsPage } from './CourtsPage'; import { RegistrationsPage } from './RegistrationsPage'; import { ClubsPage } from './ClubsPage'; import { TournamentsPage } from './TournamentsPage'; +import { GamesPage } from './GamesPage'; import type { NavItem } from '../types/navigation'; interface NavigationParams { @@ -48,7 +50,7 @@ const HomePage = ({ setCurrentPage, navItems }: { setCurrentPage: (page: string,
- +
{item.title} @@ -96,6 +98,14 @@ export const Dashboard: React.FC = () => { color: 'text-yellow-400 hover:border-yellow-400/50', path: '/tournaments' }, + { + id: 'games', + title: 'Игры', + description: 'Управление играми и матчами', + icon: Gamepad2, + color: 'text-cyan-400 hover:border-cyan-400/50', + path: '/games' + }, { id: 'courts', title: 'Корты', @@ -151,10 +161,12 @@ export const Dashboard: React.FC = () => { return ; case '/tournaments': return ; + case '/games': + return ; case '/courts': return ; case '/registrations': - return ; + return ; case '/loyalty': return ; case '/clubs': diff --git a/admin/src/components/EventResultsModal.tsx b/admin/src/components/EventResultsModal.tsx new file mode 100644 index 00000000..6bbda3e3 --- /dev/null +++ b/admin/src/components/EventResultsModal.tsx @@ -0,0 +1,379 @@ +import React, { useState, useEffect } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; +import { Button } from './ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { Badge } from './ui/badge'; +import { ScrollArea } from './ui/scroll-area'; +import { Plus, Trash2, Save, Trophy, Medal, Target, Users } from 'lucide-react'; +import { useToastContext } from '../contexts/ToastContext'; +import { eventsApi, type Event, type AdminPatchEvent } from '../api/events'; +import { registrationsApi } from '../api/registrations'; +import type { + LeaderboardEntry, + GameData, + TournamentData, + User +} from '../shared/types'; + +interface EventResultsModalProps { + event: Event | null; + isOpen: boolean; + onClose: () => void; + onUpdate: () => void; +} + +interface ParticipantOption { + userId: string; + user: User; + place?: number; +} + +export const EventResultsModal: React.FC = ({ + event, + isOpen, + onClose, + onUpdate +}) => { + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [participants, setParticipants] = useState([]); + const [leaderboard, setLeaderboard] = useState([]); + const [newParticipant, setNewParticipant] = useState(''); + + const toast = useToastContext(); + + useEffect(() => { + if (event && isOpen) { + loadEventData(); + } + }, [event, isOpen]); + + const loadEventData = async () => { + if (!event) return; + + setLoading(true); + try { + // Загружаем участников события + const registrations = await registrationsApi.filter({ eventId: event.id }); + const confirmedParticipants = registrations + .filter(reg => reg.status === 'CONFIRMED' && reg.user) + .map(reg => ({ + userId: reg.userId, + user: reg.user! + })); + + setParticipants(confirmedParticipants); + + // Загружаем существующие результаты из event.data + if (event.data) { + if (event.type === 'game') { + const gameData = event.data as GameData; + setLeaderboard(gameData.result?.leaderboard || []); + } else if (event.type === 'tournament') { + const tournamentData = event.data as TournamentData; + setLeaderboard(tournamentData.result?.leaderboard || []); + } + } else { + setLeaderboard([]); + } + } catch (error) { + toast.error('Ошибка при загрузке данных события'); + console.error('Error loading event data:', error); + } finally { + setLoading(false); + } + }; + + const handleAddToLeaderboard = () => { + if (!newParticipant) return; + + const participant = participants.find(p => p.userId === newParticipant); + if (!participant) return; + + // Проверяем, что участник еще не добавлен + if (leaderboard.some(entry => entry.userId === newParticipant)) { + toast.error('Участник уже добавлен в результаты'); + return; + } + + const nextPlace = leaderboard.length + 1; + setLeaderboard(prev => [...prev, { place: nextPlace, userId: newParticipant }]); + setNewParticipant(''); + }; + + const handleRemoveFromLeaderboard = (userId: string) => { + setLeaderboard(prev => { + const filtered = prev.filter(entry => entry.userId !== userId); + // Пересчитываем места + return filtered.map((entry, index) => ({ ...entry, place: index + 1 })); + }); + }; + + const handleMoveUp = (userId: string) => { + setLeaderboard(prev => { + const index = prev.findIndex(entry => entry.userId === userId); + if (index <= 0) return prev; + + const newLeaderboard = [...prev]; + [newLeaderboard[index - 1], newLeaderboard[index]] = [newLeaderboard[index], newLeaderboard[index - 1]]; + + // Обновляем места + return newLeaderboard.map((entry, idx) => ({ ...entry, place: idx + 1 })); + }); + }; + + const handleMoveDown = (userId: string) => { + setLeaderboard(prev => { + const index = prev.findIndex(entry => entry.userId === userId); + if (index >= prev.length - 1) return prev; + + const newLeaderboard = [...prev]; + [newLeaderboard[index], newLeaderboard[index + 1]] = [newLeaderboard[index + 1], newLeaderboard[index]]; + + // Обновляем места + return newLeaderboard.map((entry, idx) => ({ ...entry, place: idx + 1 })); + }); + }; + + const handleSave = async () => { + if (!event) return; + + setSaving(true); + try { + let updatedData: any; + + const eventData = event.data as any; + + if (event.type === 'game') { + updatedData = { + ...eventData, + game: { + type: eventData?.game?.type || eventData?.gameType || 'americano' + }, + result: { leaderboard } + }; + } else { + updatedData = { + ...eventData, + tournament: { + type: eventData?.tournament?.type || eventData?.tournamentType || 'americano' + }, + result: { leaderboard } + }; + } + + const updatePayload: AdminPatchEvent = { + data: updatedData + }; + + await eventsApi.patch(event.id, updatePayload); + toast.success('Результаты сохранены'); + onUpdate(); + onClose(); + } catch (error) { + toast.error('Ошибка при сохранении результатов'); + console.error('Error saving results:', error); + } finally { + setSaving(false); + } + }; + + const getParticipantName = (userId: string) => { + const participant = participants.find(p => p.userId === userId); + return participant ? `${participant.user.firstName} ${participant.user.lastName}` : 'Неизвестный участник'; + }; + + const getPlaceIcon = (place: number) => { + switch (place) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + default: + return ; + } + }; + + const availableParticipants = participants.filter( + p => !leaderboard.some(entry => entry.userId === p.userId) + ); + + if (!event) return null; + + return ( + + + + + {event.type === 'tournament' ? ( + + ) : ( + + )} + Результаты {event.type === 'tournament' ? 'турнира' : 'игры'}: {event.name} + + + + {loading ? ( +
+
Загрузка...
+
+ ) : participants.length === 0 ? ( +
+ +

Нет участников

+

+ В этом {event.type === 'tournament' ? 'турнире' : 'мероприятии'} пока нет подтвержденных участников. +

+ +
+ ) : ( +
+ +
+ {/* Добавление участников */} + + + + + Добавить в результаты + + + +
+ + +
+ + {availableParticipants.length === 0 && ( +
+ Все участники добавлены в результаты +
+ )} +
+
+ + {/* Текущие результаты */} + + + + + Таблица результатов ({leaderboard.length}) + + + + + {leaderboard.length === 0 ? ( +
+ Результаты пока не добавлены +
+ ) : ( +
+ {leaderboard.map((entry) => ( +
+
+ {getPlaceIcon(entry.place)} +
+ + {entry.place} место + + + {getParticipantName(entry.userId)} + +
+
+
+ + + +
+
+ ))} +
+ )} +
+
+
+
+ + {/* Кнопки действий */} +
+ + +
+
+ )} +
+
+ ); +}; \ No newline at end of file diff --git a/admin/src/components/GamesPage.tsx b/admin/src/components/GamesPage.tsx new file mode 100644 index 00000000..ffd97972 --- /dev/null +++ b/admin/src/components/GamesPage.tsx @@ -0,0 +1,1137 @@ +import React, { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; +import { Button } from './ui/button'; +import { Input } from './ui/input'; +import { Label } from './ui/label'; +import { Textarea } from './ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; +import { Badge } from './ui/badge'; +import { ScrollArea } from './ui/scroll-area'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table'; +import { Plus, Edit, Trash2, Save, X, Gamepad2, Calendar, MapPin, UserCheck, Clock, Users, Target } from 'lucide-react'; +import DatePicker, { registerLocale } from 'react-datepicker'; +import { ru } from 'date-fns/locale/ru'; +import "react-datepicker/dist/react-datepicker.css"; +import { useToastContext } from '../contexts/ToastContext'; +import { useAuth } from '../contexts/AuthContext'; +import { eventsApi, type Event, type CreateEvent, type AdminPatchEvent, type EventStatus } from '../api/events'; +import { StatusSelector } from './StatusSelector'; +import { clubsApi, type Club } from '../api/clubs'; +import { courtsApi, type Court } from '../api/courts'; +import { adminsApi } from '../api/admins'; +import { registrationsApi, type RegistrationWithPayments } from '../api/registrations'; +import { waitlistApi, type WaitlistUser } from '../api/waitlist'; +import type { AdminUser } from '../types/admin'; +import { ratingLevels, getRatingRangeDescription } from '../utils/ratingUtils'; +import { EventResultsModal } from './EventResultsModal'; + +// Регистрируем русскую локаль для DatePicker +registerLocale('ru', ru); + +interface GamesPageProps { + onNavigateToRegistrations?: (eventId: string, eventName: string) => void; +} + +export const GamesPage: React.FC = ({ onNavigateToRegistrations }) => { + const [games, setGames] = useState([]); + const [clubs, setClubs] = useState([]); + const [courts, setCourts] = useState([]); + const [admins, setAdmins] = useState([]); + const [loading, setLoading] = useState(false); + const [editingId, setEditingId] = useState(null); + const [isCreating, setIsCreating] = useState(false); + const [selectedGame, setSelectedGame] = useState(null); + const [registrations, setRegistrations] = useState([]); + const [loadingRegistrations, setLoadingRegistrations] = useState(false); + const [waitlist, setWaitlist] = useState([]); + const [loadingWaitlist, setLoadingWaitlist] = useState(false); + const [isResultsModalOpen, setIsResultsModalOpen] = useState(false); + const [resultsEvent, setResultsEvent] = useState(null); + const [isCustomGameType, setIsCustomGameType] = useState(false); + const [filters, setFilters] = useState({ + name: '', + clubId: '', + organizerId: '', + }); + const [formData, setFormData] = useState({ + name: '', + startTime: null as Date | null, + endTime: null as Date | null, + price: 0, + rankMin: 0, + rankMax: 7, + maxUsers: 4, + description: '', + courtId: '', + clubId: '', + organizerId: '', + gameType: 'americano', + customGameType: '', + }); + + const toast = useToastContext(); + const { user } = useAuth(); + + // Проверяем права доступа + const canEdit = user?.is_superuser || user?.is_active || false; + + // Предустановленные типы игр + const gameTypes = [ + { value: 'americano', label: 'Американо' }, + { value: 'mexicano', label: 'Мексиканка' }, + { value: 'training', label: 'Тренировка' }, + { value: 'custom', label: 'Ввести вручную' } + ]; + + const getGameTypeFromData = (data: any) => { + if (data?.game?.type) return data.game.type; + if (data?.gameType) return data.gameType; + return 'americano'; + }; + + const getGameTypeName = (type: string) => { + const gameType = gameTypes.find(t => t.value === type); + return gameType ? gameType.label : type; + }; + + const handleGameTypeChange = (value: string) => { + setFormData(prev => ({ ...prev, gameType: value })); + setIsCustomGameType(value === 'custom'); + }; + + const convertLocalDateToUtc = (localDate: Date): string => { + if (!localDate) return ''; + return localDate.toISOString(); + }; + + const convertUtcToLocalDate = (utcDateTime: string): Date | null => { + if (!utcDateTime) return null; + return new Date(utcDateTime); + }; + + useEffect(() => { + loadData(); + }, []); + + const loadData = async () => { + setLoading(true); + try { + await Promise.all([ + loadGames(), + loadClubs(), + loadCourts(), + loadAdmins(), + ]); + } catch (error: unknown) { + toast.error('Ошибка при загрузке данных'); + console.error('Error loading data:', error); + } finally { + setLoading(false); + } + }; + + const loadGames = async () => { + try { + const data = await eventsApi.filter({ type: 'game' }); + setGames(data); + } catch (error: unknown) { + toast.error('Ошибка при загрузке игр'); + console.error('Error loading games:', error); + } + }; + + const loadClubs = async () => { + try { + const data = await clubsApi.getAll(); + setClubs(data); + } catch (error: unknown) { + console.error('Error loading clubs:', error); + } + }; + + const loadCourts = async () => { + try { + const data = await courtsApi.getAll(); + setCourts(data); + } catch (error: unknown) { + console.error('Error loading courts:', error); + } + }; + + const loadAdmins = async () => { + try { + const data = await adminsApi.filter({}); + const adminsWithUser = data.filter((admin: AdminUser) => admin.user && admin.user.telegramUsername); + setAdmins(adminsWithUser); + } catch (error: unknown) { + console.error('Error loading admins:', error); + } + }; + + const loadRegistrations = async (eventId: string) => { + setLoadingRegistrations(true); + try { + const data = await registrationsApi.filter({ eventId }); + setRegistrations(data); + } catch (error: unknown) { + toast.error('Ошибка при загрузке регистраций'); + console.error('Error loading registrations:', error); + } finally { + setLoadingRegistrations(false); + } + }; + + const loadWaitlist = async (eventId: string) => { + setLoadingWaitlist(true); + try { + const data = await waitlistApi.getEventWaitlist(eventId); + setWaitlist(data); + } catch (error: unknown) { + toast.error('Ошибка при загрузке списка ожидания'); + console.error('Error loading waitlist:', error); + } finally { + setLoadingWaitlist(false); + } + }; + + const handleEventStatusChange = async (eventId: string, newStatus: EventStatus) => { + try { + await eventsApi.updateStatus(eventId, newStatus); + toast.success('Статус события обновлен'); + + // Обновляем локальное состояние + setGames(prev => + prev.map(game => + game.id === eventId + ? { ...game, status: newStatus } + : game + ) + ); + + // Обновляем выбранную игру если она изменилась + if (selectedGame && selectedGame.id === eventId) { + setSelectedGame((prev: any) => prev ? { ...prev, status: newStatus } : null); + } + } catch (error: unknown) { + toast.error('Ошибка при изменении статуса события'); + console.error('Error updating event status:', error); + } + }; + + const handleManageResults = (game: Event) => { + setResultsEvent(game); + setIsResultsModalOpen(true); + }; + + const handleGameSelect = (game: Event) => { + setSelectedGame(game); + setEditingId(game.id); + loadRegistrations(game.id); + loadWaitlist(game.id); + + // Заполняем форму данными игры + const gameType = getGameTypeFromData(game.data); + setFormData({ + name: game.name, + startTime: convertUtcToLocalDate(game.startTime), + endTime: game.endTime ? convertUtcToLocalDate(game.endTime) : null, + price: game.price, + rankMin: game.rankMin, + rankMax: game.rankMax, + maxUsers: game.maxUsers, + description: game.description || '', + courtId: game.court?.id || '', + clubId: game.clubId || '', + organizerId: game.organizer?.id || '', + gameType: gameType, + customGameType: '', + }); + + // Проверяем, нужно ли показывать поле для кастомного типа + const isCustomType = !gameTypes.some(t => t.value === gameType); + setIsCustomGameType(isCustomType); + if (isCustomType) { + setFormData(prev => ({ + ...prev, + customGameType: gameType + })); + } + }; + + const handleCreate = async () => { + try { + const gameType = isCustomGameType ? formData.customGameType : formData.gameType; + + if (!formData.startTime) { + toast.error('Время начала обязательно'); + return; + } + + const createData: CreateEvent = { + name: formData.name, + startTime: convertLocalDateToUtc(formData.startTime), + endTime: formData.endTime ? convertLocalDateToUtc(formData.endTime) : undefined, + price: formData.price, + rankMin: formData.rankMin, + rankMax: formData.rankMax, + maxUsers: formData.maxUsers, + description: formData.description || undefined, + courtId: formData.courtId, + clubId: formData.clubId, + type: 'game', + organizerId: formData.organizerId, + data: { gameType }, + }; + + await eventsApi.create(createData); + toast.success('Игра создана'); + + await loadGames(); + } catch (error: unknown) { + toast.error('Ошибка при создании игры'); + console.error('Error creating game:', error); + } + }; + + const handleUpdate = async (id: string) => { + try { + const gameType = isCustomGameType ? formData.customGameType : formData.gameType; + + if (!formData.startTime) { + toast.error('Время начала обязательно'); + return; + } + + const updateData: AdminPatchEvent = { + name: formData.name, + startTime: convertLocalDateToUtc(formData.startTime), + endTime: formData.endTime ? convertLocalDateToUtc(formData.endTime) : undefined, + price: formData.price, + rankMin: formData.rankMin, + rankMax: formData.rankMax, + maxUsers: formData.maxUsers, + description: formData.description || undefined, + courtId: formData.courtId, + clubId: formData.clubId, + organizerId: formData.organizerId, + data: { gameType }, + }; + + await eventsApi.patch(id, updateData); + toast.success('Игра обновлена'); + + await loadGames(); + } catch (error: unknown) { + toast.error('Ошибка при обновлении игры'); + console.error('Error updating game:', error); + } + }; + + const handleDelete = async (game: Event) => { + if (!window.confirm(`Вы уверены, что хотите удалить игру "${game.name}"?`)) { + return; + } + + try { + await eventsApi.delete(game.id); + toast.success('Игра удалена'); + await loadGames(); + + // Если удаляем текущую редактируемую игру, сбрасываем форму + if (editingId === game.id) { + resetForm(); + } + } catch (error: unknown) { + toast.error('Ошибка при удалении игры'); + console.error('Error deleting game:', error); + } + }; + + const startCreate = () => { + setIsCreating(true); + setEditingId(null); + setSelectedGame(null); + setRegistrations([]); + setWaitlist([]); + setIsCustomGameType(false); + setFormData({ + name: '', + startTime: null, + endTime: null, + price: 0, + rankMin: 0, + rankMax: 7, + maxUsers: 4, + description: '', + courtId: '', + clubId: '', + organizerId: '', + gameType: 'americano', + customGameType: '', + }); + }; + + const resetForm = () => { + setIsCreating(false); + setEditingId(null); + setSelectedGame(null); + setRegistrations([]); + setWaitlist([]); + setIsCustomGameType(false); + setFormData({ + name: '', + startTime: null, + endTime: null, + price: 0, + rankMin: 0, + rankMax: 7, + maxUsers: 4, + description: '', + courtId: '', + clubId: '', + organizerId: '', + gameType: 'americano', + customGameType: '', + }); + }; + + const handleInputChange = (field: string, value: string | number | Date | null) => { + setFormData(prev => ({ ...prev, [field]: value })); + }; + + const handleRankChange = (field: 'rankMin' | 'rankMax', levelIndex: number) => { + const level = ratingLevels[levelIndex]; + if (field === 'rankMin') { + setFormData(prev => ({ ...prev, rankMin: level.min })); + } else { + setFormData(prev => ({ ...prev, rankMax: level.max })); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!canEdit) { + toast.error('У вас нет прав для выполнения этого действия'); + return; + } + + if (isCreating) { + await handleCreate(); + } else if (editingId) { + await handleUpdate(editingId); + } + }; + + const formatDateTime = (dateTime: string) => { + if (!dateTime) return ''; + try { + const date = new Date(dateTime); + return date.toLocaleString('ru-RU', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + timeZone: 'Europe/Moscow' + }); + } catch { + return dateTime; + } + }; + + const getClubName = (clubId: string) => { + if (!clubId) return ''; + const club = clubs.find(c => c.id === clubId); + return club ? club.name : clubId; + }; + + const getCourtName = (courtId: string) => { + if (!courtId) return ''; + const court = courts.find(c => c.id === courtId); + return court ? court.name : courtId; + }; + + const getAdminName = (userId: string) => { + if (!userId) return ''; + const admin = admins.find(a => a.user_id === userId); + return admin ? `${admin.user?.firstName} ${admin.user?.lastName}` : userId; + }; + + const getSelectedMinRatingLevel = () => { + const level = ratingLevels.find(l => l.min === formData.rankMin); + return level ? `${level.label} (${level.min} - ${level.max})` : `${formData.rankMin}`; + }; + + const getSelectedMaxRatingLevel = () => { + const level = ratingLevels.find(l => l.max === formData.rankMax); + return level ? `${level.label} (${level.min} - ${level.max})` : `${formData.rankMax}`; + }; + + const filteredGames = games.filter(game => { + const nameMatch = !filters.name || game.name.toLowerCase().includes(filters.name.toLowerCase()); + const clubMatch = !filters.clubId || game.clubId === filters.clubId; + const organizatorMatch = !filters.organizerId || game.organizer?.id === filters.organizerId; + + return nameMatch && clubMatch && organizatorMatch; + }); + + if (loading) { + return ( +
+
+
+

Загрузка...

+
+
+ ); + } + + return ( +
+
+ {/* Форма создания/редактирования с табами */} + + + + {isCreating ? 'Создать игру' : editingId ? `Редактировать: ${selectedGame?.name}` : 'Форма управления'} + {(isCreating || editingId) && ( + + )} + + + + {isCreating || editingId ? ( + + + Редактирование + + Участники ({registrations.filter(r => r.status === 'CONFIRMED' || r.status === 'PENDING').length}/{selectedGame?.maxUsers}) + + + Список ожидания ({waitlist.length}) + + + + +
+
+ + handleInputChange('name', e.target.value)} + className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 mt-1" + placeholder="Например: Игра в четверг" + required + /> +
+ +
+ + +
+ + {isCustomGameType && ( +
+ + handleInputChange('customGameType', e.target.value)} + className="bg-zinc-800 border-zinc-700 text-white placeholder:text-zinc-500 mt-1" + placeholder="Например: Круговая система" + required + /> +
+ )} + +
+
+ +
+ handleInputChange('startTime', date)} + showTimeSelect + timeFormat="HH:mm" + timeIntervals={15} + dateFormat="dd.MM.yyyy HH:mm" + locale="ru" + placeholderText="Выберите дату и время" + className="w-full h-10 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-md text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + calendarClassName="react-datepicker-dark" + popperClassName="react-datepicker-popper" + wrapperClassName="w-full" + required + /> +
+
+
+ +
+ handleInputChange('endTime', date)} + showTimeSelect + timeFormat="HH:mm" + timeIntervals={15} + dateFormat="dd.MM.yyyy HH:mm" + locale="ru" + placeholderText="Выберите дату и время" + className="w-full h-10 px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-md text-white placeholder:text-zinc-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + calendarClassName="react-datepicker-dark" + popperClassName="react-datepicker-popper" + wrapperClassName="w-full" + /> +
+
+
+ +
+
+ + handleInputChange('price', parseInt(e.target.value) || 0)} + className="bg-zinc-800 border-zinc-700 text-white mt-1" + /> +
+
+ + handleInputChange('maxUsers', parseInt(e.target.value) || 2)} + className="bg-zinc-800 border-zinc-700 text-white mt-1" + /> +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +