;
-}
-
-const RegistrationDetail = ({ registration, onStatusUpdate }: RegistrationDetailProps) => {
- const [isUpdatingStatus, setIsUpdatingStatus] = useState(false);
- const [selectedStatus, setSelectedStatus] = useState(registration.status);
-
- const formatDate = (dateString: string) => {
- return new Date(dateString).toLocaleString('ru', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- };
-
- const getStatusClass = (status: string) => {
- switch (status) {
- case 'active':
- return 'bg-green-100 text-green-800';
- case 'pending':
- return 'bg-yellow-100 text-yellow-800';
- case 'canceled':
- return 'bg-red-100 text-red-800';
- case 'canceled_by_user':
- return 'bg-gray-100 text-gray-800';
- default:
- return 'bg-gray-100 text-gray-800';
- }
- };
-
- const getStatusText = (status: string) => {
- switch (status) {
- case 'active':
- return 'Активна';
- case 'pending':
- return 'В ожидании';
- case 'canceled':
- return 'Отменена';
- case 'canceled_by_user':
- return 'Отменена пользователем';
- default:
- return status;
- }
- };
-
- const getPaymentStatusClass = (status: string) => {
- switch (status) {
- case 'succeeded':
- return 'bg-green-100 text-green-800';
- case 'pending':
- return 'bg-yellow-100 text-yellow-800';
- case 'waiting_for_capture':
- return 'bg-blue-100 text-blue-800';
- case 'canceled':
- return 'bg-red-100 text-red-800';
- default:
- return 'bg-gray-100 text-gray-800';
- }
- };
-
- const getPaymentStatusText = (status: string) => {
- switch (status) {
- case 'succeeded':
- return 'Оплачен';
- case 'pending':
- return 'В ожидании';
- case 'waiting_for_capture':
- return 'Ожидает списания';
- case 'canceled':
- return 'Отменен';
- default:
- return status;
- }
- };
-
- const handleStatusUpdate = async () => {
- if (selectedStatus === registration.status) return;
-
- setIsUpdatingStatus(true);
- try {
- await onStatusUpdate(registration.id, selectedStatus);
- } catch (error) {
- console.error('Error updating status:', error);
- // Reset to original status if update failed
- setSelectedStatus(registration.status);
- } finally {
- setIsUpdatingStatus(false);
- }
- };
-
- return (
-
- {/* Registration Status */}
-
-
Статус регистрации
-
-
-
- {getStatusText(registration.status)}
-
-
- от {formatDate(registration.date)}
-
-
-
- {/* Status Update Controls */}
-
-
-
-
-
-
-
-
- {/* Registration Details */}
-
-
Детали регистрации
-
-
- ID Регистрации:
- {registration.id}
-
-
- Дата регистрации:
- {formatDate(registration.date)}
-
-
-
-
- {/* User Details */}
- {registration.user && (
-
-
Пользователь
-
-
- Имя:
-
- {registration.user.first_name} {registration.user.second_name}
-
-
-
- Город:
- {registration.user.city}
-
- {registration.user.username && (
-
- Username:
- @{registration.user.username}
-
- )}
-
-
- )}
-
- {/* Tournament Details */}
- {registration.tournament && (
-
-
Турнир
-
-
- Название:
- {registration.tournament.name}
-
-
-
- )}
-
- {/* Payments */}
-
-
- Платежи ({registration.payments?.length || 0})
-
-
- {!registration.payments || registration.payments.length === 0 ? (
-
Платежи отсутствуют
- ) : (
-
- {registration.payments
- .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
- .map((payment, index) => (
-
-
-
- Платеж #{registration.payments!.length - index}
-
-
- {getPaymentStatusText(payment.status)}
-
-
-
-
-
-
- ID: {payment.payment_id}
-
-
- Сумма: {payment.amount} руб.
-
-
- Дата: {formatDate(payment.date)}
-
-
-
- {payment.payment_link && (
-
-
-
- )}
-
-
- ))}
-
- )}
-
-
- );
-};
-
-export default RegistrationDetail;
\ No newline at end of file
diff --git a/admin/src/components/RegistrationList.tsx b/admin/src/components/RegistrationList.tsx
deleted file mode 100644
index 8e429e88..00000000
--- a/admin/src/components/RegistrationList.tsx
+++ /dev/null
@@ -1,229 +0,0 @@
-import type { Registration } from '../services/registration';
-
-interface RegistrationListProps {
- registrations: Registration[];
- onSelect: (registration: Registration) => void;
- selectedId?: string;
- currentPage: number;
- totalPages: number;
- onChangePage: (page: number) => void;
-}
-
-const RegistrationList = ({
- registrations,
- onSelect,
- selectedId,
- currentPage,
- totalPages,
- onChangePage
-}: RegistrationListProps) => {
- const formatDate = (dateString: string) => {
- return new Date(dateString).toLocaleString('ru', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- });
- };
-
- const getStatusClass = (status: string) => {
- switch (status) {
- case 'active':
- return 'bg-green-100 text-green-800';
- case 'pending':
- return 'bg-yellow-100 text-yellow-800';
- case 'canceled':
- return 'bg-red-100 text-red-800';
- case 'canceled_by_user':
- return 'bg-gray-100 text-gray-800';
- default:
- return 'bg-gray-100 text-gray-800';
- }
- };
-
- const getStatusText = (status: string) => {
- switch (status) {
- case 'active':
- return 'Активна';
- case 'pending':
- return 'В ожидании';
- case 'canceled':
- return 'Отменена';
- case 'canceled_by_user':
- return 'Отменена пользователем';
- default:
- return status;
- }
- };
-
- const getPaymentStatus = (registration: Registration) => {
- if (!registration.payments || registration.payments.length === 0) {
- return 'Нет платежей';
- }
-
- const activePayments = registration.payments.filter(p => p.status !== 'canceled');
- if (activePayments.length === 0) {
- return 'Все платежи отменены';
- }
-
- const succeededPayments = activePayments.filter(p => p.status === 'succeeded');
- if (succeededPayments.length > 0) {
- return 'Оплачено';
- }
-
- const pendingPayments = activePayments.filter(p => p.status === 'pending' || p.status === 'waiting_for_capture');
- if (pendingPayments.length > 0) {
- return 'Ожидает оплаты';
- }
-
- return 'Неизвестно';
- };
-
- const getPaymentStatusClass = (registration: Registration) => {
- if (!registration.payments || registration.payments.length === 0) {
- return 'text-gray-500';
- }
-
- const activePayments = registration.payments.filter(p => p.status !== 'canceled');
- if (activePayments.length === 0) {
- return 'text-red-500';
- }
-
- const succeededPayments = activePayments.filter(p => p.status === 'succeeded');
- if (succeededPayments.length > 0) {
- return 'text-green-600';
- }
-
- const pendingPayments = activePayments.filter(p => p.status === 'pending' || p.status === 'waiting_for_capture');
- if (pendingPayments.length > 0) {
- return 'text-yellow-600';
- }
-
- return 'text-gray-500';
- };
-
- return (
-
-
- {registrations.length === 0 ? (
-
-
-
Регистрации не найдены
-
- ) : (
-
- {registrations.map((registration) => (
-
onSelect(registration)}
- >
- {/* Mobile layout */}
-
-
-
- {registration.user ?
- `${registration.user.first_name} ${registration.user.second_name}` :
- 'Пользователь не указан'}
-
-
- {getStatusText(registration.status)}
-
-
-
- {registration.tournament ? registration.tournament.name : 'Турнир не указан'}
-
-
-
- {formatDate(registration.date)}
-
-
-
- Платежей: {registration.payments?.length || 0}
-
-
- {getPaymentStatus(registration)}
-
-
-
-
-
- {/* Desktop layout */}
-
-
-
- {registration.user ?
- `${registration.user.first_name} ${registration.user.second_name}` :
- 'Пользователь не указан'}
-
-
- {getStatusText(registration.status)}
-
-
-
- {registration.tournament ? registration.tournament.name : 'Турнир не указан'}
-
-
-
{formatDate(registration.date)}
-
-
Платежей: {registration.payments?.length || 0}
-
- {getPaymentStatus(registration)}
-
-
-
-
-
- ))}
-
- )}
-
-
- {/* Pagination */}
-
-
-
-
-
- Страница {totalPages === 0 ? 1 : currentPage} из {totalPages === 0 ? 1 : totalPages}
-
- •
-
- Всего: {registrations.length}
-
-
-
-
-
-
- );
-};
-
-export default RegistrationList;
\ No newline at end of file
diff --git a/admin-new/src/components/RegistrationsPage.tsx b/admin/src/components/RegistrationsPage.tsx
similarity index 97%
rename from admin-new/src/components/RegistrationsPage.tsx
rename to admin/src/components/RegistrationsPage.tsx
index fe02eb27..9e43b6cc 100644
--- a/admin-new/src/components/RegistrationsPage.tsx
+++ b/admin/src/components/RegistrationsPage.tsx
@@ -26,8 +26,8 @@ import {
import { useToastContext } from '../contexts/ToastContext';
import { registrationsApi } from '../api/registrations';
import type {
- Registration,
- FilterRegistration,
+ RegistrationWithPayments as Registration,
+ AdminFilterRegistration as FilterRegistration,
TournamentOption,
UserOption,
RegistrationStatus,
@@ -85,8 +85,11 @@ export const RegistrationsPage: React.FC = () => {
try {
const tournamentsData = await registrationsApi.getTournamentOptions();
setTournaments(tournamentsData);
- } catch (error: any) {
- showErrorToast(error.response?.data?.error || 'Ошибка при загрузке турниров');
+ } catch (error: unknown) {
+ const errorMessage = error && typeof error === 'object' && 'response' in error
+ ? (error as { response?: { data?: { error?: string } } }).response?.data?.error
+ : undefined;
+ showErrorToast(errorMessage || 'Ошибка при загрузке турниров');
}
};
@@ -123,7 +126,7 @@ export const RegistrationsPage: React.FC = () => {
}
if (selectedStatus) {
- filter.status = selectedStatus;
+ filter.status = selectedStatus as RegistrationStatus;
}
const data = await registrationsApi.filter(filter);
@@ -491,7 +494,7 @@ export const RegistrationsPage: React.FC = () => {
{registration.payments && registration.payments.length > 0 ? (
- registration.payments.map((payment, index) => {
+ registration.payments?.map((payment: Payment, index: number) => {
const statusConfig = getPaymentStatusConfig(payment.status);
return (
diff --git a/admin/src/components/TournamentForm.tsx b/admin/src/components/TournamentForm.tsx
deleted file mode 100644
index 174f1658..00000000
--- a/admin/src/components/TournamentForm.tsx
+++ /dev/null
@@ -1,446 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { Tournament, User, Club } from '../shared/types';
-
-import { clubService } from '../services/club';
-import { useUser } from '../context/UserContext';
-import { adminService } from '../services/admin';
-import { getRatingRangeDescription } from '../utils/ratingUtils';
-import RatingLevelSelector from './RatingLevelSelector';
-
-interface TournamentFormProps {
- tournament?: Tournament;
- onSave: (tournament: Tournament) => void;
-}
-
-const defaultTournament: Tournament = {
- name: '',
- start_time: new Date(Date.now() + 60 * 60 * 1000).toISOString().slice(0, 16),
- end_time: '',
- price: 0,
- club_id: '',
- tournament_type: '',
- rank_min: -1,
- rank_max: -1,
- max_users: 0,
- description: '',
- organizator_id: ''
-};
-
-const TournamentForm = ({ tournament, onSave }: TournamentFormProps) => {
- const [formData, setFormData] = useState (tournament || defaultTournament);
- const [errors, setErrors] = useState>>({});
- const [users, setUsers] = useState([]);
- const [clubs, setClubs] = useState([]);
- const [loadingUsers, setLoadingUsers] = useState(false);
- const [loadingClubs, setLoadingClubs] = useState(false);
- const { currentAdmin } = useUser();
- const [currentAdminUserId, setCurrentAdminUserId] = useState(null);
-
- // New states for tournament type handling
- const [isCustomType, setIsCustomType] = useState(false);
- const [customTypeValue, setCustomTypeValue] = useState('');
-
- const tournamentTypes = [
- { value: 'Американо', label: 'Американо' },
- { value: 'Мексиканка', label: 'Мексиканка' },
- { value: 'Без типа', label: 'Без типа' },
- ];
-
- useEffect(() => {
- if (tournament) {
- // Since the server now handles Moscow timezone correctly,
- // we can display the time as is for datetime-local input
- const formattedTournament = {
- ...tournament,
- start_time: tournament.start_time.slice(0, 16), // Remove timezone info for datetime-local
- end_time: tournament.end_time ? tournament.end_time.slice(0, 16) : ''
- };
- setFormData(formattedTournament);
-
- // Check if tournament type is custom (not in predefined list)
- const isPredefined = tournamentTypes.some(type => type.value === tournament.tournament_type);
- if (!isPredefined && tournament.tournament_type) {
- setIsCustomType(true);
- setCustomTypeValue(tournament.tournament_type);
- }
- } else {
- // For new tournaments, set the default values
- setFormData({
- ...defaultTournament,
- // If we have already fetched the admin's user and it exists, use it as default
- organizator_id: currentAdminUserId || defaultTournament.organizator_id
- });
- }
- }, [tournament, currentAdminUserId]);
-
- // Fetch the current admin's full profile to get user_id
- useEffect(() => {
- const fetchCurrentAdminProfile = async () => {
- if (!currentAdmin?.username) return;
-
- try {
- // Get all admins
- const admins = await adminService.getAll();
- // Find the current admin by username
- const foundAdmin = admins.find(admin => admin.username === currentAdmin.username);
-
- // If found and has a user_id, set it
- if (foundAdmin && foundAdmin.user_id) {
- setCurrentAdminUserId(foundAdmin.user_id);
-
- // If creating a new tournament, set the organizator_id
- if (!tournament) {
- setFormData(prev => ({
- ...prev,
- organizator_id: foundAdmin.user_id as string
- }));
- }
- }
- } catch (error) {
- console.error('Failed to fetch current admin profile:', error);
- }
- };
-
- fetchCurrentAdminProfile();
- }, [currentAdmin, tournament]);
-
- useEffect(() => {
- const fetchUsers = async () => {
- setLoadingUsers(true);
- try {
- const users = await adminService.getLinkedUsers();
- setUsers(users);
- } catch {
- setUsers([]);
- } finally {
- setLoadingUsers(false);
- }
- };
- fetchUsers();
- }, []);
-
- useEffect(() => {
- const fetchClubs = async () => {
- setLoadingClubs(true);
- try {
- const clubsData = await clubService.getAll();
- setClubs(clubsData);
- } catch {
- setClubs([]);
- } finally {
- setLoadingClubs(false);
- }
- };
- fetchClubs();
- }, []);
-
- const validate = (): boolean => {
- const newErrors: Partial> = {};
-
- if (!formData.name.trim()) {
- newErrors.name = 'Название турнира обязательно';
- }
-
- if (!formData.club_id) {
- newErrors.club_id = 'Клуб обязателен';
- }
-
- if (!formData.tournament_type) {
- newErrors.tournament_type = 'Тип турнира обязателен';
- }
-
- if (formData.max_users <= 0) {
- newErrors.max_users = 'Максимальное количество участников должно быть больше 0';
- }
-
- if (formData.price < 0) {
- newErrors.price = 'Цена не может быть отрицательной';
- }
-
- if (formData.rank_min === -1 || formData.rank_max === -1) {
- newErrors.rank_min = 'Необходимо выбрать допустимые уровни игроков';
- }
-
- // Check if start time is in the past (only for new tournaments)
- if (!tournament && formData.start_time) {
- const selectedDate = new Date(formData.start_time);
- const now = new Date();
- if (selectedDate < now) {
- newErrors.start_time = 'Предупреждение: Выбрана дата из прошлого';
- }
- }
-
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
- };
-
- const handleChange = (e: React.ChangeEvent) => {
- const { name, value, type } = e.target;
-
- // Special handling for tournament type
- if (name === 'tournament_type') {
- if (value === 'custom') {
- setIsCustomType(true);
- setFormData({
- ...formData,
- tournament_type: customTypeValue
- });
- } else {
- setIsCustomType(false);
- setFormData({
- ...formData,
- tournament_type: value
- });
- }
- return;
- }
-
- setFormData({
- ...formData,
- [name]: type === 'number' ? (value === '' ? undefined : Number(value)) : value
- });
- };
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
-
- if (validate()) {
- // Since the server now handles Moscow timezone correctly,
- // we can send the datetime-local value as is
-
- // Ensure all number fields have valid values (default to 0 if undefined)
- const tournamentToSave = {
- ...formData,
- price: formData.price ?? 0,
- rank_min: formData.rank_min!,
- rank_max: formData.rank_max!,
- max_users: formData.max_users ?? 0,
- start_time: formData.start_time, // Send as is - server will handle Moscow timezone
- end_time: formData.end_time || undefined,
- description: formData.description || undefined
- };
- onSave(tournamentToSave);
- }
- };
-
- // Handler for custom tournament type input
- const handleCustomTypeChange = (e: React.ChangeEvent) => {
- const value = e.target.value;
- setCustomTypeValue(value);
- setFormData({
- ...formData,
- tournament_type: value
- });
- };
-
- // Handler to switch back to predefined types
- const handleBackToPresets = () => {
- setIsCustomType(false);
- setCustomTypeValue('');
- setFormData({
- ...formData,
- tournament_type: ''
- });
- };
-
- const handleRatingChange = (minRating: number, maxRating: number) => {
- setFormData({
- ...formData,
- rank_min: minRating,
- rank_max: maxRating
- });
- };
-
- return (
-
- );
-};
-
-export default TournamentForm;
\ No newline at end of file
diff --git a/admin/src/components/TournamentList.tsx b/admin/src/components/TournamentList.tsx
deleted file mode 100644
index a24f351c..00000000
--- a/admin/src/components/TournamentList.tsx
+++ /dev/null
@@ -1,93 +0,0 @@
-import type { Tournament } from '../shared/types';
-
-interface TournamentListProps {
- tournaments: Tournament[];
- selectedId?: number;
- onSelect: (tournament: Tournament) => void;
- onDelete: (id: number) => void;
-}
-
-const TournamentList = ({ tournaments, selectedId, onSelect, onDelete }: TournamentListProps) => {
- // Sort by start time (nearest first)
- const sortedTournaments = [...tournaments]
- .sort((a, b) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
-
- if (sortedTournaments.length === 0) {
- return (
-
- Турниры не найдены
-
- );
- }
-
- // Format date for display
- const formatDate = (dateString: string): string => {
- const date = new Date(dateString);
- return new Intl.DateTimeFormat('ru', {
- day: '2-digit',
- month: '2-digit',
- year: 'numeric',
- hour: '2-digit',
- minute: '2-digit'
- }).format(date);
- };
-
- // Check if tournament is in the past
- const isPastTournament = (dateString: string): boolean => {
- return new Date(dateString) < new Date();
- };
-
- return (
-
-
- {sortedTournaments.map((tournament) => {
- const isPast = isPastTournament(tournament.start_time);
- const isSelected = tournament.id === selectedId;
-
- return (
- onSelect(tournament)}
- >
-
-
-
- {tournament.name}
- {isPast && (прошедший)}
-
-
- Начало: {formatDate(tournament.start_time)}
- {tournament.end_time && (
- Окончание: {formatDate(tournament.end_time)}
- )}
- Тип: {tournament.tournament_type}
- Цена: {tournament.price} ₽
- {tournament.description && (
-
- Описание: {tournament.description}
-
- )}
-
-
-
-
-
- );
- })}
-
-
- );
-};
-
-export default TournamentList;
\ No newline at end of file
diff --git a/admin/src/components/TournamentParticipants.tsx b/admin/src/components/TournamentParticipants.tsx
deleted file mode 100644
index e0b3cf8e..00000000
--- a/admin/src/components/TournamentParticipants.tsx
+++ /dev/null
@@ -1,181 +0,0 @@
-import { useEffect, useState } from 'react';
-import type { Participant } from '../shared/types';
-import { RegistrationStatus } from '../shared/types';
-import { tournamentService } from '../services/tournament';
-
-interface TournamentParticipantsProps {
- tournamentId: number | undefined;
-}
-
-const formatDate = (dateString: string | undefined) => {
- if (!dateString) return '—';
- try {
- const date = new Date(dateString);
- if (isNaN(date.getTime())) return '—';
- return date.toLocaleString('ru-RU', {
- year: 'numeric',
- month: 'long',
- day: 'numeric',
- hour: '2-digit',
- minute: '2-digit'
- });
- } catch {
- return '—';
- }
-};
-
-const TournamentParticipants: React.FC = ({ tournamentId }) => {
- const [participants, setParticipants] = useState([]);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
- const [updatingId, setUpdatingId] = useState(null);
- const [successMessage, setSuccessMessage] = useState(null);
-
- const fetchParticipants = async () => {
- if (!tournamentId) return;
-
- try {
- setLoading(true);
- const data = await tournamentService.getParticipants(tournamentId);
- setParticipants(data);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке списка участников');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- fetchParticipants();
- }, [tournamentId]);
-
- const handleStatusChange = async (registrationId: string, newStatus: RegistrationStatus) => {
- try {
- setUpdatingId(registrationId);
- setError(null);
- setSuccessMessage(null);
-
- await tournamentService.updateRegistrationStatus(registrationId, newStatus);
-
- await fetchParticipants();
-
- setSuccessMessage('Статус успешно обновлен');
-
- setTimeout(() => {
- setSuccessMessage(null);
- }, 3000);
- } catch (err) {
- setError('Ошибка при обновлении статуса регистрации');
- console.error(err);
- } finally {
- setUpdatingId(null);
- }
- };
-
- if (loading && participants.length === 0) {
- return Загрузка... ;
- }
-
- if (error) {
- return (
-
- {error}
-
- );
- }
-
- if (participants.length === 0) {
- return Нет зарегистрированных участников ;
- }
-
- return (
-
- Список участников ({participants.length})
-
- {successMessage && (
-
- {successMessage}
-
- )}
-
-
-
-
-
- | ФИО |
- Статус |
- Дата регистрации |
- Действия |
-
-
-
- {participants.map((participant) => (
-
-
-
- 
-
- {participant.user.first_name} {participant.user.second_name}
- @{participant.user.username || 'без имени пользователя'}
-
-
- |
-
- {participant.status === RegistrationStatus.ACTIVE && Активный}
- {participant.status === RegistrationStatus.PENDING && В ожидании}
- {participant.status === RegistrationStatus.CANCELED && Отменен}
- {participant.status === RegistrationStatus.CANCELED_BY_USER && Отменен пользователем}
- |
-
- {formatDate(participant.date)}
- |
-
-
- {updatingId === participant.id ? (
- Обновление...
- ) : (
- <>
- {participant.status !== RegistrationStatus.ACTIVE && (
-
- )}
- {participant.status !== RegistrationStatus.PENDING && participant.status !== RegistrationStatus.CANCELED && (
-
- )}
- {participant.status !== RegistrationStatus.CANCELED && (
-
- )}
- >
- )}
-
- |
-
- ))}
-
-
-
-
- );
-};
-
-export default TournamentParticipants;
\ No newline at end of file
diff --git a/admin/src/components/TournamentWaitlist.tsx b/admin/src/components/TournamentWaitlist.tsx
deleted file mode 100644
index 319f1a67..00000000
--- a/admin/src/components/TournamentWaitlist.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { useEffect, useState } from 'react';
-import type { WaitlistEntry } from '../shared/types';
-import { tournamentService } from '../services/tournament';
-
-interface TournamentWaitlistProps {
- tournamentId: number | undefined;
-}
-
-const TournamentWaitlist: React.FC = ({ tournamentId }) => {
- const [waitlist, setWaitlist] = useState([]);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- useEffect(() => {
- if (!tournamentId) return;
-
- const fetchWaitlist = async () => {
- try {
- setLoading(true);
- const data = await tournamentService.getWaitlist(tournamentId);
- setWaitlist(data);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке листа ожидания');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- fetchWaitlist();
- }, [tournamentId]);
-
- if (loading) {
- return Загрузка... ;
- }
-
- if (error) {
- return (
-
- {error}
-
- );
- }
-
- if (waitlist.length === 0) {
- return Лист ожидания пуст ;
- }
-
- return (
-
- Лист ожидания ({waitlist.length})
-
-
-
-
- | ФИО |
- Дата добавления |
-
-
-
- {waitlist.map((entry) => (
-
-
-
- 
- {entry.user.first_name} {entry.user.second_name}
-
- |
-
- {new Date(entry.date).toLocaleString('ru-RU')}
- |
-
- ))}
-
-
-
-
- );
-};
-
-export default TournamentWaitlist;
\ No newline at end of file
diff --git a/admin/src/components/TournamentsPage.tsx b/admin/src/components/TournamentsPage.tsx
new file mode 100644
index 00000000..c6525c0c
--- /dev/null
+++ b/admin/src/components/TournamentsPage.tsx
@@ -0,0 +1,1077 @@
+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, Trophy, Calendar, MapPin, UserCheck, Clock } 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 { tournamentsApi, type Tournament, type CreateTournament, type AdminPatchTournament } from '../api/tournaments';
+import { clubsApi, type Club } from '../api/clubs';
+import { courtsApi, type Court } from '../api/courts';
+import { adminsApi } from '../api/admins';
+import { registrationsApi, type RegistrationWithPayments, type RegistrationStatus } from '../api/registrations';
+import { waitlistApi, type WaitlistUser } from '../api/waitlist';
+import type { AdminUser } from '../types/admin';
+import { ratingLevels, getRatingRangeDescription } from '../utils/ratingUtils';
+
+// Регистрируем русскую локаль для DatePicker
+registerLocale('ru', ru);
+
+export const TournamentsPage: React.FC = () => {
+ const [tournaments, setTournaments] = 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 [isCustomTournamentType, setIsCustomTournamentType] = useState(false);
+ const [selectedTournament, setSelectedTournament] = useState(null);
+ const [registrations, setRegistrations] = useState([]);
+ const [loadingRegistrations, setLoadingRegistrations] = useState(false);
+ const [waitlist, setWaitlist] = useState([]);
+ const [loadingWaitlist, setLoadingWaitlist] = useState(false);
+ const [filters, setFilters] = useState({
+ name: '',
+ clubId: '',
+ organizatorId: '',
+ });
+ 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: '',
+ tournamentType: 'tournament',
+ customTournamentType: '',
+ organizatorId: '',
+ });
+
+ const toast = useToastContext();
+ const { user } = useAuth();
+
+ // Проверяем права доступа
+ const canEdit = user?.is_superuser || user?.is_active || false;
+
+ // Предустановленные типы турниров
+ const tournamentTypes = [
+ { value: 'americano', label: 'Американо' },
+ { value: 'mexicano', label: 'Мексиканка' },
+ { value: 'training', label: 'Тренировка' },
+ { value: 'custom', label: 'Ввести вручную' }
+ ];
+
+ 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([
+ loadTournaments(),
+ loadClubs(),
+ loadCourts(),
+ loadAdmins(),
+ ]);
+ } catch (error: unknown) {
+ toast.error('Ошибка при загрузке данных');
+ console.error('Error loading data:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const loadTournaments = async () => {
+ try {
+ const data = await tournamentsApi.getAll();
+ setTournaments(data);
+ } catch (error: unknown) {
+ toast.error('Ошибка при загрузке турниров');
+ console.error('Error loading tournaments:', 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 (tournamentId: string) => {
+ setLoadingRegistrations(true);
+ try {
+ const data = await registrationsApi.filter({ tournamentId });
+ setRegistrations(data);
+ } catch (error: unknown) {
+ toast.error('Ошибка при загрузке регистраций');
+ console.error('Error loading registrations:', error);
+ } finally {
+ setLoadingRegistrations(false);
+ }
+ };
+
+ const loadWaitlist = async (tournamentId: string) => {
+ setLoadingWaitlist(true);
+ try {
+ const data = await waitlistApi.getTournamentWaitlist(tournamentId);
+ setWaitlist(data);
+ } catch (error: unknown) {
+ toast.error('Ошибка при загрузке списка ожидания');
+ console.error('Error loading waitlist:', error);
+ } finally {
+ setLoadingWaitlist(false);
+ }
+ };
+
+ const handleStatusChange = async (registrationId: string, newStatus: RegistrationStatus) => {
+ try {
+ await registrationsApi.updateStatus(registrationId, newStatus);
+ toast.success('Статус регистрации обновлен');
+
+ // Обновляем локальное состояние
+ setRegistrations(prev =>
+ prev.map(reg =>
+ reg.id === registrationId
+ ? { ...reg, status: newStatus }
+ : reg
+ )
+ );
+ } catch (error: unknown) {
+ toast.error('Ошибка при изменении статуса');
+ console.error('Error updating registration status:', error);
+ }
+ };
+
+ const handleTournamentSelect = (tournament: Tournament) => {
+ setSelectedTournament(tournament);
+ setEditingId(tournament.id);
+ loadRegistrations(tournament.id);
+ loadWaitlist(tournament.id);
+
+ // Заполняем форму данными турнира
+ setFormData({
+ name: tournament.name,
+ startTime: convertUtcToLocalDate(tournament.startTime),
+ endTime: tournament.endTime ? convertUtcToLocalDate(tournament.endTime) : null,
+ price: tournament.price,
+ rankMin: tournament.rankMin,
+ rankMax: tournament.rankMax,
+ maxUsers: tournament.maxUsers,
+ description: tournament.description || '',
+ courtId: tournament.court?.id || '',
+ clubId: tournament.clubId,
+ tournamentType: tournament.tournamentType,
+ customTournamentType: '',
+ organizatorId: tournament.organizator?.id || '',
+ });
+
+ // Проверяем, нужно ли показывать поле для кастомного типа
+ const isCustomType = !tournamentTypes.some(t => t.value === tournament.tournamentType);
+ setIsCustomTournamentType(isCustomType);
+ if (isCustomType) {
+ setFormData(prev => ({
+ ...prev,
+ customTournamentType: tournament.tournamentType
+ }));
+ }
+ };
+
+ const handleCreate = async () => {
+ try {
+ const tournamentType = isCustomTournamentType ? formData.customTournamentType : formData.tournamentType;
+
+ if (!formData.startTime) {
+ toast.error('Время начала обязательно');
+ return;
+ }
+
+ const createData: CreateTournament = {
+ 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,
+ tournamentType: tournamentType,
+ organizatorId: formData.organizatorId,
+ };
+
+ await tournamentsApi.create(createData);
+ toast.success('Турнир создан');
+
+ await loadTournaments();
+ } catch (error: unknown) {
+ toast.error('Ошибка при создании турнира');
+ console.error('Error creating tournament:', error);
+ }
+ };
+
+ const handleUpdate = async (id: string) => {
+ try {
+ const tournamentType = isCustomTournamentType ? formData.customTournamentType : formData.tournamentType;
+
+ if (!formData.startTime) {
+ toast.error('Время начала обязательно');
+ return;
+ }
+
+ const updateData: AdminPatchTournament = {
+ 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,
+ tournamentType: tournamentType,
+ organizatorId: formData.organizatorId,
+ };
+
+ await tournamentsApi.patch(id, updateData);
+ toast.success('Турнир обновлен');
+
+ await loadTournaments();
+ } catch (error: unknown) {
+ toast.error('Ошибка при обновлении турнира');
+ console.error('Error updating tournament:', error);
+ }
+ };
+
+ const handleDelete = async (tournament: Tournament) => {
+ if (!window.confirm(`Вы уверены, что хотите удалить турнир "${tournament.name}"?`)) {
+ return;
+ }
+
+ try {
+ await tournamentsApi.delete(tournament.id);
+ toast.success('Турнир удален');
+ await loadTournaments();
+
+ // Если удаляем текущий редактируемый турнир, сбрасываем форму
+ if (editingId === tournament.id) {
+ resetForm();
+ }
+ } catch (error: unknown) {
+ toast.error('Ошибка при удалении турнира');
+ console.error('Error deleting tournament:', error);
+ }
+ };
+
+
+
+ const startCreate = () => {
+ setIsCreating(true);
+ setEditingId(null);
+ setSelectedTournament(null);
+ setRegistrations([]);
+ setWaitlist([]);
+ setFormData({
+ name: '',
+ startTime: null,
+ endTime: null,
+ price: 0,
+ rankMin: 0,
+ rankMax: 7,
+ maxUsers: 4,
+ description: '',
+ courtId: '',
+ clubId: '',
+ tournamentType: 'tournament',
+ customTournamentType: '',
+ organizatorId: '',
+ });
+ setIsCustomTournamentType(false);
+ };
+
+ const resetForm = () => {
+ setIsCreating(false);
+ setEditingId(null);
+ setSelectedTournament(null);
+ setRegistrations([]);
+ setWaitlist([]);
+ setFormData({
+ name: '',
+ startTime: null,
+ endTime: null,
+ price: 0,
+ rankMin: 0,
+ rankMax: 7,
+ maxUsers: 4,
+ description: '',
+ courtId: '',
+ clubId: '',
+ tournamentType: 'tournament',
+ customTournamentType: '',
+ organizatorId: '',
+ });
+ setIsCustomTournamentType(false);
+ };
+
+ const handleInputChange = (field: string, value: string | number | Date | null) => {
+ setFormData(prev => ({ ...prev, [field]: value }));
+ };
+
+ const handleTournamentTypeChange = (value: string) => {
+ setFormData(prev => ({ ...prev, tournamentType: value }));
+ setIsCustomTournamentType(value === 'custom');
+ };
+
+ 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) => {
+ const club = clubs.find(c => c.id === clubId);
+ return club ? club.name : clubId;
+ };
+
+ const getCourtName = (courtId: string) => {
+ const court = courts.find(c => c.id === courtId);
+ return court ? court.name : courtId;
+ };
+
+ const getAdminName = (userId: string) => {
+ const admin = admins.find(a => a.user_id === userId);
+ return admin ? `${admin.user?.firstName} ${admin.user?.lastName}` : userId;
+ };
+
+ const getTournamentTypeName = (type: string) => {
+ const tournamentType = tournamentTypes.find(t => t.value === type);
+ return tournamentType ? tournamentType.label : type;
+ };
+
+ 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 filteredTournaments = tournaments.filter(tournament => {
+ const nameMatch = !filters.name || tournament.name.toLowerCase().includes(filters.name.toLowerCase());
+ const clubMatch = !filters.clubId || tournament.clubId === filters.clubId;
+ const organizatorMatch = !filters.organizatorId || tournament.organizator?.id === filters.organizatorId;
+
+ return nameMatch && clubMatch && organizatorMatch;
+ });
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {/* Форма создания/редактирования с табами */}
+
+
+
+ {isCreating ? 'Создать турнир' : editingId ? `Редактировать: ${selectedTournament?.name}` : 'Форма управления'}
+ {(isCreating || editingId) && (
+
+ )}
+
+
+
+ {isCreating || editingId ? (
+
+
+ Редактирование
+
+ Участники ({registrations.filter(r => r.status === 'ACTIVE').length}/{registrations.filter(r => r.status === 'ACTIVE' || r.status === 'PENDING').length})
+
+
+ Список ожидания ({waitlist.length})
+
+
+
+
+
+
+
+
+
+
+
+
+ Участники
+
+
+
+ {loadingRegistrations ? (
+
+
+ Загрузка регистраций...
+
+ ) : (
+
+ {registrations.length === 0 ? (
+ Нет участников
+ ) : (
+
+
+
+ Участник
+ Telegram
+ Рейтинг
+ Статус
+
+
+
+ {registrations.map((registration) => (
+
+
+ {registration.user?.firstName} {registration.user?.lastName}
+
+
+
+ @{registration.user?.telegramUsername}
+
+
+
+ {registration.user?.rank || 0}
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+ Список ожидания
+
+
+
+ {loadingWaitlist ? (
+
+
+ Загрузка списка ожидания...
+
+ ) : (
+
+ {waitlist.length === 0 ? (
+ Список ожидания пуст
+ ) : (
+
+
+
+ Участник
+ Telegram
+ Рейтинг
+ Город
+ Дата добавления
+
+
+
+ {waitlist.map((waitlistEntry, index) => (
+
+
+ {waitlistEntry.user?.firstName} {waitlistEntry.user?.lastName}
+
+
+ @{waitlistEntry.user?.telegramUsername}
+
+
+ {waitlistEntry.user?.rank || 0}
+
+
+ {waitlistEntry.user?.city || '-'}
+
+
+ {new Date(waitlistEntry.date).toLocaleDateString('ru-RU')}
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+
+ ) : (
+
+
+ Выберите турнир для редактирования или создайте новый
+
+
+ )}
+
+
+
+ {/* Список турниров */}
+
+
+
+ Турниры ({filteredTournaments.length})
+ {canEdit && (
+
+ )}
+
+
+
+ {/* Фильтры */}
+
+
+
+
+ setFilters(prev => ({ ...prev, name: e.target.value }))}
+ className="bg-zinc-700 border-zinc-600 text-white placeholder:text-zinc-500 mt-1"
+ placeholder="Введите название турнира"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {filteredTournaments.length === 0 ? (
+
+
+ Турниры не найдены
+ {canEdit && (
+
+ )}
+
+ ) : (
+
+
+ {filteredTournaments.map((tournament) => (
+ handleTournamentSelect(tournament)}
+ >
+
+
+
+
+ {tournament.name}
+
+
+ {getTournamentTypeName(tournament.tournamentType)}
+
+
+
+
+
+
+ {formatDateTime(tournament.startTime)}
+
+
+
+ {getCourtName(tournament.court?.id || '')}
+
+
+
+ {tournament.description && (
+ {tournament.description}
+ )}
+
+
+
+ {getClubName(tournament.clubId)}
+
+
+ {tournament.price}₽
+
+
+ {getRatingRangeDescription(tournament.rankMin, tournament.rankMax)}
+
+
+
+
+ {canEdit && (
+
+
+
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+
+
+ );
+};
\ No newline at end of file
diff --git a/admin/src/components/UserAssociationModal.tsx b/admin/src/components/UserAssociationModal.tsx
deleted file mode 100644
index d863f591..00000000
--- a/admin/src/components/UserAssociationModal.tsx
+++ /dev/null
@@ -1,173 +0,0 @@
-import { useState, useEffect } from 'react';
-import { userService } from '../services/user';
-import type { User } from '../shared/types';
-import { getRatingWord } from '../utils/ratingUtils';
-
-interface UserAssociationModalProps {
- adminId: string;
- currentUserId?: string;
- onCancel: () => void;
- onSave: (adminId: string, userId: string) => void;
-}
-
-const UserAssociationModal = ({
- adminId,
- currentUserId,
- onCancel,
- onSave,
-}: UserAssociationModalProps) => {
- const [users, setUsers] = useState([]);
- const [selectedUserId, setSelectedUserId] = useState(currentUserId || '');
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [searchTerm, setSearchTerm] = useState('');
- const [page, setPage] = useState(0);
- const [hasMore, setHasMore] = useState(true);
- const LIMIT = 50;
-
- useEffect(() => {
- fetchUsers();
- }, [page]);
-
- const fetchUsers = async () => {
- try {
- setLoading(true);
- const result = await userService.getAll(page * LIMIT, LIMIT);
- if (page === 0) {
- setUsers(result.users);
- } else {
- setUsers(prevUsers => [...prevUsers, ...result.users]);
- }
- setHasMore(result.users.length === LIMIT);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке пользователей');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (selectedUserId) {
- onSave(adminId, selectedUserId);
- }
- };
-
- const filteredUsers = users.filter(user => {
- if (!searchTerm) return true;
-
- const fullName = `${user.first_name} ${user.second_name}`.toLowerCase();
- const username = user.username?.toLowerCase() || '';
- const telegramId = String(user.telegram_id).toLowerCase();
- const city = user.city.toLowerCase();
- const search = searchTerm.toLowerCase();
-
- return fullName.includes(search) ||
- username.includes(search) ||
- telegramId.includes(search) ||
- city.includes(search);
- });
-
- const loadMore = () => {
- if (hasMore && !loading) {
- setPage(p => p + 1);
- }
- };
-
- return (
-
-
-
- Выбор пользователя
-
-
-
- setSearchTerm(e.target.value)}
- />
-
-
- {error && (
- {error}
- )}
-
-
-
- {filteredUsers.length > 0 ? (
- filteredUsers.map(user => (
- setSelectedUserId(user.id)}
- >
-
-
- {user.avatar && (
- 
- )}
-
-
- {user.first_name} {user.second_name}
-
-
- {user.username ? (
- @{user.username}
- ) : (
- ID: {user.telegram_id}
- )}
-
- {user.city}, рейтинг: {getRatingWord(user.rank)}
-
-
-
-
- ))
- ) : (
-
- {loading ? 'Загрузка...' : 'Пользователи не найдены'}
-
- )}
-
- {hasMore && filteredUsers.length > 0 && (
-
-
-
- )}
-
-
-
-
-
-
-
-
-
- );
-};
-
-export default UserAssociationModal;
\ No newline at end of file
diff --git a/admin/src/components/UserForm.tsx b/admin/src/components/UserForm.tsx
deleted file mode 100644
index e94195af..00000000
--- a/admin/src/components/UserForm.tsx
+++ /dev/null
@@ -1,241 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { User, Loyalty } from '../shared/types';
-import RatingSelector from './RatingSelector';
-
-interface UserFormProps {
- user: User;
- loyalties: Loyalty[];
- onSave: (user: Partial) => void;
- loading?: boolean;
-}
-
-const UserForm = ({ user, loyalties, onSave, loading = false }: UserFormProps) => {
- const [formData, setFormData] = useState>({});
- const [errors, setErrors] = useState>>({});
-
- useEffect(() => {
- setFormData({
- first_name: user.first_name,
- second_name: user.second_name,
- birth_date: user.birth_date ? user.birth_date.split('T')[0] : undefined,
- city: user.city,
- rank: user.rank,
- playing_position: user.playing_position,
- padel_profiles: user.padel_profiles,
- bio: user.bio,
- loyalty_id: user.loyalty_id,
- is_registered: user.is_registered
- });
- }, [user]);
-
- const validate = (): boolean => {
- const newErrors: Partial> = {};
-
- if (formData.first_name !== undefined && !formData.first_name.trim()) {
- newErrors.first_name = 'Имя обязательно';
- }
-
- if (formData.second_name !== undefined && !formData.second_name.trim()) {
- newErrors.second_name = 'Фамилия обязательна';
- }
-
- if (formData.rank !== undefined && (formData.rank < 0 || formData.rank > 7)) {
- newErrors.rank = 'Рейтинг должен быть от 0 до 7';
- }
-
- setErrors(newErrors);
- return Object.keys(newErrors).length === 0;
- };
-
- const handleChange = (e: React.ChangeEvent) => {
- const { name, value, type } = e.target as HTMLInputElement;
-
- if (name === 'rank' && type === 'number' && value !== '') {
- const numValue = Number(value);
- if (numValue > 7) {
- e.target.value = '7';
- setFormData({
- ...formData,
- [name]: 7
- });
- return;
- } else if (numValue < 0) {
- e.target.value = '0';
- setFormData({
- ...formData,
- [name]: 0
- });
- return;
- }
- }
-
- setFormData({
- ...formData,
- [name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked :
- type === 'number' || name === 'loyalty_id' ? (value === '' ? undefined : Number(value)) : value
- });
- };
-
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
-
- if (validate()) {
- onSave(formData);
- }
- };
-
- return (
-
- );
-};
-
-export default UserForm;
\ No newline at end of file
diff --git a/admin/src/components/UserList.tsx b/admin/src/components/UserList.tsx
deleted file mode 100644
index 9000383c..00000000
--- a/admin/src/components/UserList.tsx
+++ /dev/null
@@ -1,147 +0,0 @@
-import type { UserListItem } from '../shared/types';
-import { getRatingWord } from '../utils/ratingUtils';
-
-interface UserListProps {
- users: UserListItem[];
- selectedId?: string;
- onSelect: (user: UserListItem) => void;
- currentPage: number;
- totalPages: number;
- onPageChange: (page: number) => void;
-}
-
-const UserList = ({ users, selectedId, onSelect, currentPage, totalPages, onPageChange }: UserListProps) => {
- if (users.length === 0) {
- return (
-
- Пользователи не найдены
-
- );
- }
-
- return (
-
-
-
- {users.map((user) => (
- - onSelect(user)}
- >
-
-
- {user.avatar && (
- 
- )}
-
-
-
- {user.first_name} {user.second_name}
-
-
-
-
- {user.username ? `@${user.username}` : `ID: ${user.telegram_id}`}
-
-
- {user.is_registered ? 'Рег.' : 'Не рег.'}
-
-
-
- {user.city}
- Рейтинг: {getRatingWord(user.rank)}
-
-
-
-
-
- ))}
-
-
-
- {/* Pagination */}
- {totalPages > 1 && (
-
-
-
-
-
-
-
-
- Страница {currentPage} из {totalPages}
-
-
-
-
-
-
-
- )}
-
- );
-};
-
-export default UserList;
\ No newline at end of file
diff --git a/admin-new/src/components/UserModal.tsx b/admin/src/components/UserModal.tsx
similarity index 100%
rename from admin-new/src/components/UserModal.tsx
rename to admin/src/components/UserModal.tsx
diff --git a/admin-new/src/components/UsersPage.tsx b/admin/src/components/UsersPage.tsx
similarity index 100%
rename from admin-new/src/components/UsersPage.tsx
rename to admin/src/components/UsersPage.tsx
diff --git a/admin-new/src/components/ui/avatar.tsx b/admin/src/components/ui/avatar.tsx
similarity index 100%
rename from admin-new/src/components/ui/avatar.tsx
rename to admin/src/components/ui/avatar.tsx
diff --git a/admin-new/src/components/ui/badge.tsx b/admin/src/components/ui/badge.tsx
similarity index 100%
rename from admin-new/src/components/ui/badge.tsx
rename to admin/src/components/ui/badge.tsx
diff --git a/admin-new/src/components/ui/button.tsx b/admin/src/components/ui/button.tsx
similarity index 100%
rename from admin-new/src/components/ui/button.tsx
rename to admin/src/components/ui/button.tsx
diff --git a/admin-new/src/components/ui/card.tsx b/admin/src/components/ui/card.tsx
similarity index 100%
rename from admin-new/src/components/ui/card.tsx
rename to admin/src/components/ui/card.tsx
diff --git a/admin-new/src/components/ui/dialog.tsx b/admin/src/components/ui/dialog.tsx
similarity index 100%
rename from admin-new/src/components/ui/dialog.tsx
rename to admin/src/components/ui/dialog.tsx
diff --git a/admin-new/src/components/ui/input.tsx b/admin/src/components/ui/input.tsx
similarity index 100%
rename from admin-new/src/components/ui/input.tsx
rename to admin/src/components/ui/input.tsx
diff --git a/admin-new/src/components/ui/label.tsx b/admin/src/components/ui/label.tsx
similarity index 100%
rename from admin-new/src/components/ui/label.tsx
rename to admin/src/components/ui/label.tsx
diff --git a/admin-new/src/components/ui/scroll-area.tsx b/admin/src/components/ui/scroll-area.tsx
similarity index 100%
rename from admin-new/src/components/ui/scroll-area.tsx
rename to admin/src/components/ui/scroll-area.tsx
diff --git a/admin-new/src/components/ui/select.tsx b/admin/src/components/ui/select.tsx
similarity index 91%
rename from admin-new/src/components/ui/select.tsx
rename to admin/src/components/ui/select.tsx
index 7ed682c8..73114108 100644
--- a/admin-new/src/components/ui/select.tsx
+++ b/admin/src/components/ui/select.tsx
@@ -15,6 +15,7 @@ interface SelectTriggerProps {
interface SelectValueProps {
placeholder?: string
+ children?: React.ReactNode
}
interface SelectContentProps {
@@ -67,8 +68,15 @@ export const SelectTrigger: React.FC = ({ className, childre
)
}
-export const SelectValue: React.FC = ({ placeholder }) => {
+export const SelectValue: React.FC = ({ placeholder, children }) => {
const { value } = React.useContext(SelectContext)
+
+ // Если есть children и value, показываем children
+ if (children && value) {
+ return {children}
+ }
+
+ // Иначе показываем value или placeholder
return {value || placeholder}
}
diff --git a/admin-new/src/components/ui/switch.tsx b/admin/src/components/ui/switch.tsx
similarity index 100%
rename from admin-new/src/components/ui/switch.tsx
rename to admin/src/components/ui/switch.tsx
diff --git a/admin/src/components/ui/table.tsx b/admin/src/components/ui/table.tsx
new file mode 100644
index 00000000..3fd5daf8
--- /dev/null
+++ b/admin/src/components/ui/table.tsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import { cn } from '../../lib/utils';
+
+interface TableProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const Table: React.FC = ({ children, className }) => {
+ return (
+
+ );
+};
+
+interface TableHeaderProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const TableHeader: React.FC = ({ children, className }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+interface TableBodyProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const TableBody: React.FC = ({ children, className }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+interface TableRowProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const TableRow: React.FC = ({ children, className }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+interface TableHeadProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const TableHead: React.FC = ({ children, className }) => {
+ return (
+
+ {children}
+ |
+ );
+};
+
+interface TableCellProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const TableCell: React.FC = ({ children, className }) => {
+ return (
+
+ {children}
+ |
+ );
+};
\ No newline at end of file
diff --git a/admin/src/components/ui/tabs.tsx b/admin/src/components/ui/tabs.tsx
new file mode 100644
index 00000000..055ae5d9
--- /dev/null
+++ b/admin/src/components/ui/tabs.tsx
@@ -0,0 +1,102 @@
+import React, { createContext, useContext, useState } from 'react';
+import { cn } from '../../lib/utils';
+
+interface TabsContextType {
+ activeTab: string;
+ setActiveTab: (tab: string) => void;
+}
+
+const TabsContext = createContext(undefined);
+
+interface TabsProps {
+ defaultValue: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const Tabs: React.FC = ({ defaultValue, children, className }) => {
+ const [activeTab, setActiveTab] = useState(defaultValue);
+
+ return (
+
+
+ {children}
+
+
+ );
+};
+
+interface TabsListProps {
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const TabsList: React.FC = ({ children, className }) => {
+ return (
+
+ {children}
+
+ );
+};
+
+interface TabsTriggerProps {
+ value: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const TabsTrigger: React.FC = ({ value, children, className }) => {
+ const context = useContext(TabsContext);
+ if (!context) {
+ throw new Error('TabsTrigger must be used within Tabs');
+ }
+
+ const { activeTab, setActiveTab } = context;
+ const isActive = activeTab === value;
+
+ return (
+
+ );
+};
+
+interface TabsContentProps {
+ value: string;
+ children: React.ReactNode;
+ className?: string;
+}
+
+export const TabsContent: React.FC = ({ value, children, className }) => {
+ const context = useContext(TabsContext);
+ if (!context) {
+ throw new Error('TabsContent must be used within Tabs');
+ }
+
+ const { activeTab } = context;
+
+ if (activeTab !== value) {
+ return null;
+ }
+
+ return (
+
+ {children}
+
+ );
+};
\ No newline at end of file
diff --git a/admin-new/src/components/ui/textarea.tsx b/admin/src/components/ui/textarea.tsx
similarity index 100%
rename from admin-new/src/components/ui/textarea.tsx
rename to admin/src/components/ui/textarea.tsx
diff --git a/admin-new/src/components/ui/toast.tsx b/admin/src/components/ui/toast.tsx
similarity index 100%
rename from admin-new/src/components/ui/toast.tsx
rename to admin/src/components/ui/toast.tsx
diff --git a/admin/src/context/UserContext.tsx b/admin/src/context/UserContext.tsx
deleted file mode 100644
index 8c4cb8c4..00000000
--- a/admin/src/context/UserContext.tsx
+++ /dev/null
@@ -1,78 +0,0 @@
-import { createContext, useState, useContext, useEffect } from 'react';
-import type { ReactNode } from 'react';
-import { api } from '../api/api';
-
-interface User {
- username: string;
- isAdmin: boolean;
-}
-
-export interface CurrentAdmin {
- username: string;
- is_superuser: boolean;
-}
-
-interface UserContextType {
- user: User | null;
- loading: boolean;
- currentAdmin: CurrentAdmin | null;
- setUser: (user: User | null) => void;
- fetchUser: () => Promise;
-}
-
-const UserContext = createContext(undefined);
-
-export const UserProvider = ({ children }: { children: ReactNode }) => {
- const [user, setUser] = useState(null);
- const [currentAdmin, setCurrentAdmin] = useState(null);
- const [loading, setLoading] = useState(true);
-
- const fetchUser = async () => {
- setLoading(true);
- const token = localStorage.getItem('token');
-
- if (token) {
- try {
- const response = await api.get('/admin/auth/me');
- setUser({
- username: response.data.username,
- isAdmin: response.data.is_superuser
- });
- setCurrentAdmin({
- username: response.data.username,
- is_superuser: response.data.is_superuser
- });
- } catch (error) {
- console.error('Failed to fetch user data', error);
- setUser(null);
- setCurrentAdmin(null);
- }
- } else {
- setUser(null);
- setCurrentAdmin(null);
- }
- setLoading(false);
- };
-
- useEffect(() => {
- if (localStorage.getItem('token')) {
- fetchUser();
- } else {
- setLoading(false);
- }
- }, []);
-
- return (
-
- {children}
-
- );
-};
-
-export const useUser = (): UserContextType => {
- const context = useContext(UserContext);
- if (context === undefined) {
- throw new Error('useUser must be used within a UserProvider');
- }
- return context;
-};
\ No newline at end of file
diff --git a/admin-new/src/contexts/AuthContext.tsx b/admin/src/contexts/AuthContext.tsx
similarity index 100%
rename from admin-new/src/contexts/AuthContext.tsx
rename to admin/src/contexts/AuthContext.tsx
diff --git a/admin-new/src/contexts/ToastContext.tsx b/admin/src/contexts/ToastContext.tsx
similarity index 100%
rename from admin-new/src/contexts/ToastContext.tsx
rename to admin/src/contexts/ToastContext.tsx
diff --git a/admin-new/src/hooks/useToast.ts b/admin/src/hooks/useToast.ts
similarity index 100%
rename from admin-new/src/hooks/useToast.ts
rename to admin/src/hooks/useToast.ts
diff --git a/admin/src/index.css b/admin/src/index.css
index 9048e801..814609ba 100644
--- a/admin/src/index.css
+++ b/admin/src/index.css
@@ -15,3 +15,138 @@ body {
min-width: 320px;
min-height: 100vh;
}
+
+
+
+@layer base {
+ :root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.141 0.005 285.823);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.141 0.005 285.823);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.141 0.005 285.823);
+ --primary: oklch(0.21 0.006 285.885);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.967 0.001 286.375);
+ --secondary-foreground: oklch(0.21 0.006 285.885);
+ --muted: oklch(0.967 0.001 286.375);
+ --muted-foreground: oklch(0.552 0.016 285.938);
+ --accent: oklch(0.967 0.001 286.375);
+ --accent-foreground: oklch(0.21 0.006 285.885);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.92 0.004 286.32);
+ --input: oklch(0.92 0.004 286.32);
+ --ring: oklch(0.705 0.015 286.067);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.141 0.005 285.823);
+ --sidebar-primary: oklch(0.21 0.006 285.885);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.967 0.001 286.375);
+ --sidebar-accent-foreground: oklch(0.21 0.006 285.885);
+ --sidebar-border: oklch(0.92 0.004 286.32);
+ --sidebar-ring: oklch(0.705 0.015 286.067);
+ }
+
+ .dark {
+ --background: oklch(0.141 0.005 285.823);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.21 0.006 285.885);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.21 0.006 285.885);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.92 0.004 286.32);
+ --primary-foreground: oklch(0.21 0.006 285.885);
+ --secondary: oklch(0.274 0.006 286.033);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.274 0.006 286.033);
+ --muted-foreground: oklch(0.705 0.015 286.067);
+ --accent: oklch(0.274 0.006 286.033);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.552 0.016 285.938);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.21 0.006 285.885);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.274 0.006 286.033);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.552 0.016 285.938);
+ }
+}
+
+
+
+@layer base {
+ * {
+ @apply border-border;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+}
+
+/* Кастомные стили для scrollbar */
+.scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: #52525b #27272a;
+}
+
+.scrollbar-thin::-webkit-scrollbar {
+ width: 8px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-track {
+ background: #27272a;
+ border-radius: 4px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb {
+ background: #52525b;
+ border-radius: 4px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb:hover {
+ background: #71717a;
+}
+
+/* Стили для календаря */
+input[type="datetime-local"]::-webkit-calendar-picker-indicator {
+ color: white;
+ background: transparent;
+ cursor: pointer;
+ filter: invert(1);
+ opacity: 0.8;
+}
+
+input[type="datetime-local"]::-webkit-calendar-picker-indicator:hover {
+ opacity: 1;
+}
+
+input[type="datetime-local"]::-webkit-inner-spin-button {
+ color: white;
+}
+
+/* Стили для выпадающих списков */
+.bg-zinc-800 {
+ color-scheme: dark;
+}
+
+/* Убираем стили по умолчанию для select */
+select {
+ color-scheme: dark;
+}
diff --git a/admin-new/src/lib/utils.ts b/admin/src/lib/utils.ts
similarity index 100%
rename from admin-new/src/lib/utils.ts
rename to admin/src/lib/utils.ts
diff --git a/admin/src/pages/AdminsPage.tsx b/admin/src/pages/AdminsPage.tsx
deleted file mode 100644
index 7b26aff1..00000000
--- a/admin/src/pages/AdminsPage.tsx
+++ /dev/null
@@ -1,225 +0,0 @@
-import { useState, useEffect } from 'react';
-import AdminList from '../components/AdminList';
-import AdminForm from '../components/AdminForm';
-import PasswordChangeForm from '../components/PasswordChangeForm';
-import UserAssociationModal from '../components/UserAssociationModal';
-import { adminService } from '../services/admin';
-import type { AdminUser, CreateAdminRequest, PasswordChangeRequest } from '../services/admin';
-import { useUser } from '../context/UserContext';
-import { AxiosError } from 'axios';
-
-interface ErrorResponse {
- detail: string;
-}
-
-const AdminsPage = () => {
- const [admins, setAdmins] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [successMessage, setSuccessMessage] = useState(null);
- const [showUserModal, setShowUserModal] = useState(false);
- const [selectedAdminId, setSelectedAdminId] = useState(null);
- const [activeTab, setActiveTab] = useState<'add' | 'password'>('add');
- const { currentAdmin } = useUser();
-
- const fetchAdmins = async () => {
- try {
- setLoading(true);
- const data = await adminService.getAll();
- setAdmins(data);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке администраторов');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- fetchAdmins();
- }, []);
-
- const handleAddAdmin = async (adminData: CreateAdminRequest) => {
- try {
- setLoading(true);
- await adminService.create(adminData);
- await fetchAdmins();
- setSuccessMessage('Администратор успешно добавлен');
- setTimeout(() => setSuccessMessage(null), 3000);
- setError(null);
- } catch (err: unknown) {
- const axiosError = err as AxiosError;
- setError(axiosError.response?.data?.detail || 'Ошибка при создании администратора');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- const handleDeleteAdmin = async (adminId: string) => {
- if (!window.confirm('Вы действительно хотите удалить этого администратора?')) {
- return;
- }
-
- try {
- setLoading(true);
- await adminService.delete(adminId);
- await fetchAdmins();
- setSuccessMessage('Администратор успешно удален');
- setTimeout(() => setSuccessMessage(null), 3000);
- setError(null);
- } catch (err: unknown) {
- const axiosError = err as AxiosError;
- setError(axiosError.response?.data?.detail || 'Ошибка при удалении администратора');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- const handleChangePassword = async (passwordData: PasswordChangeRequest) => {
- try {
- setLoading(true);
- await adminService.changePassword(passwordData);
- setSuccessMessage('Пароль успешно изменен');
- setTimeout(() => setSuccessMessage(null), 3000);
- setError(null);
- } catch (err: unknown) {
- const axiosError = err as AxiosError;
- setError(axiosError.response?.data?.detail || 'Ошибка при изменении пароля');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- const handleEditUserAssociation = (adminId: string) => {
- setSelectedAdminId(adminId);
- setShowUserModal(true);
- };
-
- const handleSaveUserAssociation = async (adminId: string, userId: string) => {
- try {
- setLoading(true);
- await adminService.updateUserAssociation(adminId, userId);
- await fetchAdmins();
- setShowUserModal(false);
- setSelectedAdminId(null);
- setSuccessMessage('Пользователь успешно привязан к администратору');
- setTimeout(() => setSuccessMessage(null), 3000);
- setError(null);
- } catch (err: unknown) {
- const axiosError = err as AxiosError;
- setError(axiosError.response?.data?.detail || 'Ошибка при привязке пользователя');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- const handleCancelUserAssociation = () => {
- setShowUserModal(false);
- setSelectedAdminId(null);
- };
-
- const getSelectedAdminUserId = () => {
- if (!selectedAdminId) return undefined;
- const admin = admins.find(a => a.id === selectedAdminId);
- return admin?.user_id;
- };
-
- return (
-
- Администраторы
-
- {error && (
-
- {error}
-
- )}
-
- {successMessage && (
-
- {successMessage}
-
- )}
-
-
- {/* Left column - Admin list */}
-
-
- Список администраторов
-
-
- {loading ? (
- Загрузка...
- ) : admins.length > 0 ? (
-
- ) : (
- Нет доступных администраторов
- )}
-
-
-
- {/* Right column - Forms section */}
-
-
-
-
-
-
- {activeTab === 'add' ? (
- currentAdmin?.is_superuser ? (
-
- ) : (
-
- Только суперпользователи могут добавлять новых администраторов.
-
- )
- ) : (
-
- )}
-
-
-
-
- {/* User association modal */}
- {showUserModal && selectedAdminId && (
-
- )}
-
- );
-};
-
-export default AdminsPage;
\ No newline at end of file
diff --git a/admin/src/pages/ClubsPage.tsx b/admin/src/pages/ClubsPage.tsx
deleted file mode 100644
index 108a8eb4..00000000
--- a/admin/src/pages/ClubsPage.tsx
+++ /dev/null
@@ -1,219 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { Club } from '../shared/types';
-import { clubService } from '../services/club';
-
-const ClubsPage = () => {
- const [clubs, setClubs] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [selectedClub, setSelectedClub] = useState(null);
- const [isCreating, setIsCreating] = useState(false);
- const [formData, setFormData] = useState({
- name: '',
- address: ''
- });
-
- const fetchClubs = async () => {
- try {
- setLoading(true);
- const data = await clubService.getAll();
- setClubs(data);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке клубов');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- fetchClubs();
- }, []);
-
- const handleSelectClub = (club: Club) => {
- setSelectedClub(club);
- setIsCreating(false);
- setFormData({
- name: club.name,
- address: club.address
- });
- };
-
- const handleCreateNew = () => {
- setSelectedClub(null);
- setIsCreating(true);
- setFormData({
- name: '',
- address: ''
- });
- };
-
- const handleSave = async () => {
- try {
- setLoading(true);
- if (isCreating) {
- await clubService.create(formData);
- } else if (selectedClub?.id) {
- await clubService.update(selectedClub.id, formData);
- }
- await fetchClubs();
- setIsCreating(false);
- setSelectedClub(null);
- setFormData({ name: '', address: '' });
- } catch (err) {
- setError('Ошибка при сохранении клуба');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- const handleDelete = async (id: string) => {
- if (window.confirm('Вы уверены, что хотите удалить этот клуб?')) {
- try {
- setLoading(true);
- await clubService.delete(id);
- await fetchClubs();
- if (selectedClub?.id === id) {
- setSelectedClub(null);
- }
- } catch (err) {
- setError('Ошибка при удалении клуба');
- console.error(err);
- } finally {
- setLoading(false);
- }
- }
- };
-
- const handleCancel = () => {
- setSelectedClub(null);
- setIsCreating(false);
- setFormData({ name: '', address: '' });
- };
-
- return (
-
- Клубы
-
- {error && (
-
- {error}
-
- )}
-
-
- {/* Left side - Club list */}
-
-
- Список клубов
-
-
-
-
- {loading && clubs.length === 0 ? (
- Загрузка...
- ) : clubs.length > 0 ? (
-
- {clubs.map((club) => (
- handleSelectClub(club)}
- >
-
-
- {club.name}
- {club.address}
-
-
-
-
- ))}
-
- ) : (
- Нет клубов
- )}
-
-
-
- {/* Right side - Club form */}
-
- {(isCreating || selectedClub) ? (
-
-
- {isCreating ? 'Создание нового клуба' : 'Редактирование клуба'}
-
-
-
-
-
- setFormData({ ...formData, name: e.target.value })}
- className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500"
- placeholder="Введите название клуба"
- />
-
-
-
-
-
-
-
-
-
-
-
-
- ) : (
-
- Выберите клуб из списка или создайте новый
-
- )}
-
-
-
- );
-};
-
-export default ClubsPage;
\ No newline at end of file
diff --git a/admin/src/pages/DashboardPage.tsx b/admin/src/pages/DashboardPage.tsx
deleted file mode 100644
index acb54aaa..00000000
--- a/admin/src/pages/DashboardPage.tsx
+++ /dev/null
@@ -1,79 +0,0 @@
-import React, { useState, useEffect } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { dashboardService } from '../services/dashboardService';
-import type { DashboardData } from '../services/dashboardService';
-
-const DashboardPage: React.FC = () => {
- const navigate = useNavigate();
- const [loading, setLoading] = useState(true);
- const [dashboardData, setDashboardData] = useState(null);
-
- useEffect(() => {
- const fetchData = async () => {
- try {
- setLoading(true);
- const data = await dashboardService.getDashboardData();
- setDashboardData(data);
- } catch (error) {
- console.error('Error fetching dashboard data:', error);
- } finally {
- setLoading(false);
- }
- };
-
- fetchData();
- }, []);
-
- // Get today's date in Russian format
- const today = new Date().toLocaleDateString('ru-RU', {
- weekday: 'long',
- year: 'numeric',
- month: 'long',
- day: 'numeric'
- });
-
- // First letter uppercase
- const capitalizedToday = today.charAt(0).toUpperCase() + today.slice(1);
-
- if (loading) {
- return (
-
-
-
- Загружаем данные...
-
-
- );
- }
-
- return (
-
- {/* Welcome Banner */}
-
- GO PADEL
- Сегодня {capitalizedToday}
-
-
- {/* Navigation Cards Grid */}
-
- {dashboardData?.navigationCards.map((card, index) => (
- navigate(card.path)}
- >
-
- {card.icon}
-
- {card.title}
- {card.description}
-
-
-
- ))}
-
-
- );
-};
-
-export default DashboardPage;
\ No newline at end of file
diff --git a/admin/src/pages/LoginPage.tsx b/admin/src/pages/LoginPage.tsx
deleted file mode 100644
index e2190f2f..00000000
--- a/admin/src/pages/LoginPage.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import { useState } from 'react';
-import { useNavigate } from 'react-router-dom';
-import { authService } from '../services/auth';
-import { useUser } from '../context/UserContext';
-
-export default function LoginPage() {
- const [username, setUsername] = useState('');
- const [password, setPassword] = useState('');
- const [error, setError] = useState('');
- const [isLoading, setIsLoading] = useState(false);
- const navigate = useNavigate();
- const { fetchUser } = useUser();
-
- const handleSubmit = async (e: React.FormEvent) => {
- e.preventDefault();
- setError('');
- setIsLoading(true);
-
- try {
- await authService.login({ username, password });
- await fetchUser();
- navigate('/dashboard');
- } catch (err: unknown) {
- const error = err as { response?: { data?: { detail?: string } } };
- setError(error.response?.data?.detail || 'Ошибка авторизации');
- } finally {
- setIsLoading(false);
- }
- };
-
- return (
-
-
-
- GO PADEL
- Вход для администраторов
-
-
- {error && (
-
- )}
-
-
-
-
- );
-}
\ No newline at end of file
diff --git a/admin/src/pages/LoyaltyPage.tsx b/admin/src/pages/LoyaltyPage.tsx
deleted file mode 100644
index efae222e..00000000
--- a/admin/src/pages/LoyaltyPage.tsx
+++ /dev/null
@@ -1,197 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { Loyalty } from '../shared/types';
-import { loyaltyService } from '../services/loyalty';
-import LoyaltyForm from '../components/LoyaltyForm';
-import LoyaltyList from '../components/LoyaltyList';
-
-const LoyaltyPage = () => {
- const [loyalties, setLoyalties] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [selectedLoyalty, setSelectedLoyalty] = useState(null);
- const [isCreating, setIsCreating] = useState(false);
- const [nameFilter, setNameFilter] = useState('');
-
- const fetchLoyalties = async () => {
- try {
- setLoading(true);
- const data = await loyaltyService.getAll();
- setLoyalties(data);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке уровней лояльности');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- fetchLoyalties();
- }, []);
-
- const handleSelectLoyalty = (loyalty: Loyalty) => {
- setSelectedLoyalty(loyalty);
- setIsCreating(false);
- };
-
- const handleCreateNew = () => {
- setSelectedLoyalty(null);
- setIsCreating(true);
- };
-
- const handleSave = async (loyalty: Loyalty) => {
- try {
- setLoading(true);
- let savedLoyalty: Loyalty;
-
- if (isCreating) {
- savedLoyalty = await loyaltyService.create(loyalty);
- } else if (selectedLoyalty?.id) {
- savedLoyalty = await loyaltyService.update({
- ...loyalty,
- id: selectedLoyalty.id
- });
- } else {
- throw new Error('Invalid state');
- }
-
- // Fetch updated data and keep the saved loyalty selected
- const updatedLoyalties = await loyaltyService.getAll();
- setLoyalties(updatedLoyalties);
-
- // Find the saved loyalty in the updated list by ID
- const updatedLoyalty = updatedLoyalties.find(item => item.id === savedLoyalty.id);
-
- if (updatedLoyalty) {
- setSelectedLoyalty(updatedLoyalty);
- }
-
- setIsCreating(false);
- } catch (err) {
- setError('Ошибка при сохранении уровня лояльности');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- const handleDelete = async (id: number) => {
- if (window.confirm('Вы уверены, что хотите удалить этот уровень лояльности?')) {
- try {
- setLoading(true);
- await loyaltyService.delete(id);
- await fetchLoyalties();
- if (selectedLoyalty?.id === id) {
- setSelectedLoyalty(null);
- }
- } catch (err) {
- setError('Ошибка при удалении уровня лояльности');
- console.error(err);
- } finally {
- setLoading(false);
- }
- }
- };
-
- // Filter loyalties by name
- const filteredLoyalties = loyalties.filter(loyalty => {
- if (nameFilter && !loyalty.name.toLowerCase().includes(nameFilter.toLowerCase())) {
- return false;
- }
- return true;
- });
-
- return (
-
- Уровни лояльности
-
- {error && (
-
- {error}
-
- )}
-
-
- {/* Left side - Loyalty list */}
-
-
- Список уровней
-
-
-
- {/* Filter input */}
-
-
- setNameFilter(e.target.value)}
- className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-600"
- />
-
-
-
- {nameFilter && (
-
- )}
-
-
-
-
- {loading && loyalties.length === 0 ? (
- Загрузка...
- ) : filteredLoyalties.length > 0 ? (
-
- ) : (
-
- {nameFilter ? 'Нет уровней, соответствующих фильтру' : 'Нет доступных уровней лояльности'}
-
- )}
-
-
-
- {/* Right side - Loyalty form */}
-
- {isCreating ? (
- <>
- Создание нового уровня лояльности
-
- >
- ) : selectedLoyalty ? (
- <>
- Редактирование уровня лояльности
-
- >
- ) : (
-
- Выберите уровень лояльности из списка или создайте новый
-
- )}
-
-
-
- );
-};
-
-export default LoyaltyPage;
\ No newline at end of file
diff --git a/admin/src/pages/RegistrationsPage.tsx b/admin/src/pages/RegistrationsPage.tsx
deleted file mode 100644
index 80c1372f..00000000
--- a/admin/src/pages/RegistrationsPage.tsx
+++ /dev/null
@@ -1,294 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { Registration } from '../services/registration';
-import { registrationService } from '../services/registration';
-import { userService } from '../services/user';
-import { tournamentService } from '../services/tournament';
-import type { UserListItem, Tournament } from '../shared/types';
-import RegistrationList from '../components/RegistrationList';
-import RegistrationDetail from '../components/RegistrationDetail';
-
-const RegistrationsPage = () => {
- // Registration states
- const [registrations, setRegistrations] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [selectedRegistration, setSelectedRegistration] = useState(null);
-
- // Pagination states
- const [currentPage, setCurrentPage] = useState(1);
- const [totalRegistrations, setTotalRegistrations] = useState(0);
- const [registrationsPerPage] = useState(5);
-
- // Filter states
- const [isFilterExpanded, setIsFilterExpanded] = useState(false);
- const [userFilter, setUserFilter] = useState('');
- const [tournamentFilter, setTournamentFilter] = useState('');
- const [statusFilter, setStatusFilter] = useState('all');
-
- // Dropdown data
- const [users, setUsers] = useState([]);
- const [tournaments, setTournaments] = useState([]);
- const [loadingUsers, setLoadingUsers] = useState(false);
- const [loadingTournaments, setLoadingTournaments] = useState(false);
-
- // Mobile state for showing detail view
- const [showDetail, setShowDetail] = useState(false);
-
- // Calculate total pages
- const totalPages = Math.ceil(totalRegistrations / registrationsPerPage);
-
- const fetchRegistrations = async (page: number = 1) => {
- try {
- setLoading(true);
- const skip = (page - 1) * registrationsPerPage;
- const filters: { user_id?: string; tournament_id?: string; status?: string } = {};
- if (userFilter) filters.user_id = userFilter;
- if (tournamentFilter) filters.tournament_id = tournamentFilter;
- if (statusFilter && statusFilter !== 'all') filters.status = statusFilter;
- const { registrations: fetchedRegistrations, total } = await registrationService.getAll(skip, registrationsPerPage, filters);
- setRegistrations(fetchedRegistrations);
- setTotalRegistrations(total);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке регистраций');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- const fetchUsers = async () => {
- try {
- setLoadingUsers(true);
- const users = await userService.getAllUsers();
- setUsers(users);
- } catch (err) {
- console.error('Error fetching users:', err);
- } finally {
- setLoadingUsers(false);
- }
- };
-
- const fetchTournaments = async () => {
- try {
- setLoadingTournaments(true);
- const fetchedTournaments = await tournamentService.getAll();
- setTournaments(fetchedTournaments);
- } catch (err) {
- console.error('Error fetching tournaments:', err);
- } finally {
- setLoadingTournaments(false);
- }
- };
-
- useEffect(() => {
- // Load all required data
- const initData = async () => {
- await Promise.all([
- fetchRegistrations(currentPage),
- fetchUsers(),
- fetchTournaments()
- ]);
- };
-
- initData();
- }, []);
-
- useEffect(() => {
- // Re-fetch registrations when page changes or filters change
- fetchRegistrations(currentPage);
- }, [currentPage, userFilter, tournamentFilter, statusFilter]);
-
- const handleSelectRegistration = (registration: Registration) => {
- setSelectedRegistration(registration);
- setShowDetail(true); // Show detail view on mobile
- };
-
- const handleChangePage = (page: number) => {
- setCurrentPage(page);
- };
-
- const handleStatusUpdate = async (registrationId: string, newStatus: string) => {
- try {
- await registrationService.updateStatus(registrationId, newStatus);
- // Refresh registrations list
- await fetchRegistrations(currentPage);
- // Update selected registration if it was updated
- if (selectedRegistration?.id === registrationId) {
- const updatedRegistration = registrations.find(r => r.id === registrationId);
- if (updatedRegistration) {
- setSelectedRegistration(updatedRegistration);
- }
- }
- } catch (err) {
- setError('Ошибка при обновлении статуса регистрации');
- console.error(err);
- }
- };
-
- const clearFilters = () => {
- setUserFilter('');
- setTournamentFilter('');
- setStatusFilter('all');
- };
-
- const handleBackToList = () => {
- setShowDetail(false);
- setSelectedRegistration(null);
- };
-
- return (
-
-
- Регистрации
-
- {/* Mobile back button */}
- {showDetail && selectedRegistration && (
-
- )}
-
-
- {error && (
-
- {error}
-
- )}
-
-
- {/* Left side - Registration list */}
-
-
- Список регистраций
-
-
- {/* Filters section */}
-
-
-
- {isFilterExpanded && (
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {loading ? (
-
- ) : registrations.length > 0 ? (
-
- ) : (
-
-
- Регистрации не найдены
-
- )}
-
-
-
- {/* Right side - Registration details */}
-
- {selectedRegistration ? (
-
- ) : (
-
-
- Выберите регистрацию
- Нажмите на регистрацию в списке слева, чтобы просмотреть детали
-
- )}
-
-
-
- );
-};
-
-export default RegistrationsPage;
\ No newline at end of file
diff --git a/admin/src/pages/TournamentsPage.tsx b/admin/src/pages/TournamentsPage.tsx
deleted file mode 100644
index ef5886ea..00000000
--- a/admin/src/pages/TournamentsPage.tsx
+++ /dev/null
@@ -1,352 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { Tournament } from '../shared/types';
-import { tournamentService } from '../services/tournament';
-import TournamentForm from '../components/TournamentForm';
-import TournamentList from '../components/TournamentList';
-import TournamentParticipants from '../components/TournamentParticipants';
-import TournamentWaitlist from '../components/TournamentWaitlist';
-import { useLocation } from 'react-router-dom';
-
-const TournamentsPage = () => {
- const [tournaments, setTournaments] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [successMessage, setSuccessMessage] = useState(null);
- const [selectedTournament, setSelectedTournament] = useState(null);
- const [isCreating, setIsCreating] = useState(false);
- const [showPastTournaments, setShowPastTournaments] = useState(false);
-
- // Search and filter states
- const [nameFilter, setNameFilter] = useState('');
- const [startDateFilter, setStartDateFilter] = useState('');
- const [endDateFilter, setEndDateFilter] = useState('');
-
- const [activeTab, setActiveTab] = useState<'edit' | 'participants' | 'waitlist'>('edit');
-
- const location = useLocation();
-
- const fetchTournaments = async () => {
- try {
- setLoading(true);
- const data = await tournamentService.getAll();
- setTournaments(data);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке турниров');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- fetchTournaments();
- }, []);
-
- // Выделяем турнир по query-параметру
- useEffect(() => {
- const params = new URLSearchParams(location.search);
- const tournamentId = params.get('tournamentId');
- if (tournamentId && tournaments.length > 0) {
- const found = tournaments.find(t => String(t.id) === String(tournamentId));
- if (found) {
- setSelectedTournament(found);
- }
- }
- }, [location.search, tournaments]);
-
-
-
- const handleSelectTournament = (tournament: Tournament) => {
- setSelectedTournament(tournament);
- setIsCreating(false);
- };
-
- const handleCreateNew = () => {
- setSelectedTournament(null);
- setIsCreating(true);
- };
-
- const handleSave = async (tournament: Tournament) => {
- try {
- setLoading(true);
- setError(null);
- setSuccessMessage(null);
- let savedTournament: Tournament;
-
- if (isCreating) {
- savedTournament = await tournamentService.create(tournament);
- // После создания оставляем пользователя на форме редактирования созданного турнира
- setIsCreating(false);
- setSelectedTournament(savedTournament);
- setSuccessMessage('Турнир успешно создан!');
- } else if (selectedTournament?.id) {
- savedTournament = await tournamentService.update({
- ...tournament,
- id: selectedTournament.id
- });
- // После обновления оставляем пользователя на той же форме редактирования
- setSelectedTournament(savedTournament);
- setSuccessMessage('Турнир успешно обновлён!');
- }
-
- await fetchTournaments();
-
- // Скрыть уведомление через 3 секунды
- setTimeout(() => {
- setSuccessMessage(null);
- }, 3000);
- } catch (err) {
- setError('Ошибка при сохранении турнира');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- const handleDelete = async (id: number) => {
- if (window.confirm('Вы уверены, что хотите удалить этот турнир?')) {
- try {
- setLoading(true);
- await tournamentService.delete(id);
- await fetchTournaments();
- if (selectedTournament?.id === id) {
- setSelectedTournament(null);
- }
- } catch (err) {
- setError('Ошибка при удалении турнира');
- console.error(err);
- } finally {
- setLoading(false);
- }
- }
- };
-
- const clearFilters = () => {
- setNameFilter('');
- setStartDateFilter('');
- setEndDateFilter('');
- };
-
- // Apply filters to tournaments
- const filteredTournaments = tournaments.filter(tournament => {
- // Filter by past/upcoming based on checkbox
- if (!showPastTournaments && new Date(tournament.start_time) < new Date()) {
- return false;
- }
-
- // Filter by name (case insensitive)
- if (nameFilter && !tournament.name.toLowerCase().includes(nameFilter.toLowerCase())) {
- return false;
- }
-
- // Filter by start date
- if (startDateFilter && new Date(tournament.start_time) < new Date(startDateFilter)) {
- return false;
- }
-
- // Filter by end date
- if (endDateFilter && new Date(tournament.start_time) > new Date(endDateFilter)) {
- return false;
- }
-
- return true;
- });
-
- // Right side - Tournament form or details
- const renderRightSide = () => {
- if (isCreating) {
- return (
- <>
- Создание нового турнира
-
- >
- );
- }
-
- if (selectedTournament) {
- return (
- <>
-
-
-
-
- {activeTab === 'edit' && (
-
-
-
- )}
-
- {activeTab === 'participants' && (
-
-
-
- )}
-
- {activeTab === 'waitlist' && (
-
-
-
- )}
- >
- );
- }
-
- return (
-
- Выберите турнир из списка или создайте новый
-
- );
- };
-
- return (
-
- Турниры
-
- {error && (
-
- {error}
-
- )}
-
- {successMessage && (
-
- {successMessage}
-
- )}
-
-
- {/* Left side - Tournament list */}
-
-
- Список турниров
-
-
-
- {/* Filters section */}
-
-
- Фильтры
-
-
-
-
-
-
- setNameFilter(e.target.value)}
- placeholder="Введите название турнира"
- className="w-full px-2 py-1 text-sm border border-gray-300 rounded"
- />
-
-
-
-
-
-
- {/* Past tournaments toggle */}
-
-
-
-
-
- {loading && tournaments.length === 0 ? (
- Загрузка...
- ) : filteredTournaments.length > 0 ? (
-
- ) : (
-
- {nameFilter || startDateFilter || endDateFilter
- ? 'Нет турниров, соответствующих фильтрам'
- : 'Нет доступных турниров'}
-
- )}
-
-
-
- {/* Right side - Tournament form */}
-
- {renderRightSide()}
-
-
-
- );
-};
-
-export default TournamentsPage;
\ No newline at end of file
diff --git a/admin/src/pages/UsersPage.tsx b/admin/src/pages/UsersPage.tsx
deleted file mode 100644
index 4ae3e15f..00000000
--- a/admin/src/pages/UsersPage.tsx
+++ /dev/null
@@ -1,327 +0,0 @@
-import { useState, useEffect } from 'react';
-import type { User, UserListItem, Loyalty } from '../shared/types';
-import { userService } from '../services/user';
-import { loyaltyService } from '../services/loyalty';
-import UserForm from '../components/UserForm';
-import UserList from '../components/UserList';
-import { useLocation } from 'react-router-dom';
-
-const UsersPage = () => {
- // For query parameters handling
- const location = useLocation();
-
- // User states
- const [users, setUsers] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [selectedUser, setSelectedUser] = useState(null);
- const [loadingUser, setLoadingUser] = useState(false);
-
- // Loyalty states
- const [loyalties, setLoyalties] = useState([]);
- const [loadingLoyalty, setLoadingLoyalty] = useState(true);
-
- // Search and filter states
- const [searchFilter, setSearchFilter] = useState('');
- const [isRegisteredFilter, setIsRegisteredFilter] = useState('all');
-
- // Flag to track if we're loading a user from query params
- const [isLoadingFromQueryParams, setIsLoadingFromQueryParams] = useState(false);
-
- // Parse URL query parameters to get userId
- useEffect(() => {
- const queryParams = new URLSearchParams(location.search);
- const userId = queryParams.get('userId');
-
- if (userId) {
- setIsLoadingFromQueryParams(true);
- loadUserById(userId);
- }
- }, [location.search]);
-
- // Load a specific user by ID
- const loadUserById = async (userId: string) => {
- try {
- setLoadingUser(true);
- const user = await userService.getById(userId);
-
- // Make sure loyalties are loaded before setting selected user
- if (loyalties.length === 0) {
- await fetchLoyalties();
- }
-
- // Add loyalty object to the user
- const loyaltyObj = loyalties.find(l => l.id === user.loyalty_id);
- const userWithLoyalty = {
- ...user,
- loyalty: loyaltyObj
- };
-
- setSelectedUser(userWithLoyalty);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке пользователя');
- console.error(err);
- } finally {
- setLoadingUser(false);
- setIsLoadingFromQueryParams(false);
- }
- };
-
- const fetchLoyalties = async () => {
- try {
- setLoadingLoyalty(true);
- const data = await loyaltyService.getAll();
- setLoyalties(data);
- return data;
- } catch (err) {
- console.error('Error fetching loyalty levels:', err);
- return [];
- } finally {
- setLoadingLoyalty(false);
- }
- };
-
- const fetchUsers = async () => {
- try {
- setLoading(true);
- const fetchedUsers = await userService.getAllUsers();
-
- setUsers(fetchedUsers);
- setError(null);
- } catch (err) {
- setError('Ошибка при загрузке пользователей');
- console.error(err);
- } finally {
- setLoading(false);
- }
- };
-
- useEffect(() => {
- // First load loyalties, then load users
- const initData = async () => {
- const loadedLoyalties = await fetchLoyalties();
-
- // If we're not loading a specific user from query params, load the user list
- if (!isLoadingFromQueryParams) {
- await fetchUsers();
- }
-
- // If we have a userId in the URL, load that specific user
- const queryParams = new URLSearchParams(location.search);
- const userId = queryParams.get('userId');
-
- if (userId && loadedLoyalties.length > 0) {
- loadUserById(userId);
- }
- };
-
- initData();
- }, []); // Empty dependency array - only run once
-
- const handleSelectUser = (user: UserListItem) => {
- // Load full user information when selecting from list
- loadUserById(user.id);
- };
-
- // Removed pagination functionality
-
- const handleSave = async (updatedUserData: Partial) => {
- if (!selectedUser) return;
-
- try {
- setLoadingUser(true);
- const updatedUser = await userService.update({
- id: selectedUser.id,
- ...updatedUserData
- });
-
- // Update the specific user in the list without reloading everything
- setUsers(prevUsers => prevUsers.map(user =>
- user.id === updatedUser.id
- ? {
- id: user.id,
- telegram_id: user.telegram_id,
- username: updatedUser.username,
- first_name: updatedUser.first_name,
- second_name: updatedUser.second_name,
- avatar: updatedUser.avatar,
- city: updatedUser.city,
- rank: updatedUser.rank,
- is_registered: updatedUser.is_registered
- }
- : user
- ));
-
- // Update the selected user with returned data and loyalty info
- const loyaltyObj = loyalties.find(l => l.id === updatedUser.loyalty_id);
- setSelectedUser({
- ...updatedUser,
- loyalty: loyaltyObj
- });
-
- setError(null);
- } catch (err) {
- setError('Ошибка при сохранении данных пользователя');
- console.error(err);
- } finally {
- setLoadingUser(false);
- }
- };
-
- // Local client-side filtering
- // Note: In a production app with many users, these filters should be moved to the server-side API
- const filteredUsers = users.filter(user => {
- const fullName = `${user.first_name} ${user.second_name}`.toLowerCase();
- const username = user.username ? user.username.toLowerCase() : '';
-
- // Filter by name, surname or username
- if (searchFilter) {
- const searchTerm = searchFilter.toLowerCase();
- if (!fullName.includes(searchTerm) && !username.includes(searchTerm)) {
- return false;
- }
- }
-
- // Filter by registration status
- if (isRegisteredFilter !== 'all') {
- const isRegistered = isRegisteredFilter === 'registered';
- if (user.is_registered !== isRegistered) {
- return false;
- }
- }
-
- return true;
- });
-
-
-
-
-
- return (
-
- Пользователи
-
- {error && (
-
- {error}
-
- )}
-
-
- {/* Left side - User list */}
-
-
-
-
- Список пользователей
-
- Всего: {users.length}
- {(searchFilter || isRegisteredFilter !== 'all') &&
- ` | Отфильтровано: ${filteredUsers.length}`}
-
-
-
-
-
- {/* Search section */}
-
-
- setSearchFilter(e.target.value)}
- className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-600"
- />
-
-
-
- {searchFilter && (
-
- )}
-
-
-
-
-
-
-
-
-
- {loading && users.length === 0 ? (
- Загрузка...
- ) : filteredUsers.length > 0 ? (
- {}}
- />
- ) : (
-
- {searchFilter || isRegisteredFilter !== 'all'
- ? 'Нет пользователей, соответствующих фильтрам'
- : 'Нет доступных пользователей'}
-
- )}
-
-
-
- {/* Right side - User form */}
-
- {loadingUser ? (
-
- Загрузка пользователя...
-
- ) : selectedUser && loyalties.length > 0 && !loadingLoyalty ? (
- <>
-
- Редактирование пользователя
-
-
- {selectedUser.avatar && (
- 
- )}
-
-
- {selectedUser.first_name} {selectedUser.second_name}
-
- {selectedUser.username ? `@${selectedUser.username}` : `ID: ${selectedUser.telegram_id}`}
-
-
-
-
-
- >
- ) : (
-
- Выберите пользователя из списка для редактирования
-
- )}
-
-
-
- );
-};
-
-export default UsersPage;
\ No newline at end of file
diff --git a/admin/src/services/admin.ts b/admin/src/services/admin.ts
deleted file mode 100644
index df15d0bd..00000000
--- a/admin/src/services/admin.ts
+++ /dev/null
@@ -1,62 +0,0 @@
-import { api } from "../api/api";
-import type { User } from "../shared/types";
-
-export interface AdminUser {
- id: string;
- username: string;
- first_name: string;
- last_name: string;
- is_superuser: boolean;
- is_active: boolean;
- user_id?: string;
-}
-
-export interface CreateAdminRequest {
- username: string;
- password: string;
- first_name: string;
- last_name: string;
- is_superuser?: boolean;
- is_active?: boolean;
- user_id?: string;
-}
-
-export interface PasswordChangeRequest {
- old_password: string;
- new_password: string;
-}
-
-export const adminService = {
- getAll: async (): Promise => {
- const response = await api.get('/admin/');
- return response.data;
- },
-
- create: async (adminData: CreateAdminRequest): Promise => {
- const response = await api.post('/admin/create', adminData);
- return response.data;
- },
-
- delete: async (adminId: string): Promise => {
- await api.delete(`/admin/${adminId}`);
- },
-
- changePassword: async (passwordData: PasswordChangeRequest): Promise => {
- await api.post('/admin/auth/change-password', passwordData);
- },
-
- getCurrentAdmin: async (): Promise<{ username: string; is_superuser: boolean }> => {
- const response = await api.get('/admin/auth/me');
- return response.data;
- },
-
- updateUserAssociation: async (adminId: string, userId: string): Promise => {
- const response = await api.patch(`/admin/${adminId}/user`, { user_id: userId });
- return response.data;
- },
-
- getLinkedUsers: async (): Promise => {
- const response = await api.get('/admin/linked-users/');
- return response.data;
- }
-};
\ No newline at end of file
diff --git a/admin/src/services/auth.ts b/admin/src/services/auth.ts
deleted file mode 100644
index fc7f2d68..00000000
--- a/admin/src/services/auth.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { api } from "../api/api";
-
-interface LoginRequest {
- username: string;
- password: string;
-}
-
-interface LoginResponse {
- access_token: string;
- token_type: string;
-}
-
-export const authService = {
- login: async (credentials: LoginRequest): Promise => {
- const response = await api.post('/admin/auth/login', credentials);
- localStorage.setItem('token', response.data.access_token);
- return response.data;
- },
-
- logout: () => {
- localStorage.removeItem('token');
- },
-
- isAuthenticated: (): boolean => {
- return !!localStorage.getItem('token');
- }
-};
\ No newline at end of file
diff --git a/admin/src/services/club.ts b/admin/src/services/club.ts
deleted file mode 100644
index 2d0cf394..00000000
--- a/admin/src/services/club.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import type { Club } from '../shared/types';
-import { api } from "../api/api";
-
-export const clubService = {
- async getAll(): Promise {
- const response = await api.get("/admin/clubs/");
- return response.data;
- },
-
- async getById(id: string): Promise {
- const response = await api.get(`/admin/clubs/${id}`);
- return response.data;
- },
-
- async create(clubData: Omit): Promise {
- const response = await api.post("/admin/clubs/", clubData);
- return response.data;
- },
-
- async update(id: string, clubData: Omit): Promise {
- const response = await api.put(`/admin/clubs/${id}`, clubData);
- return response.data;
- },
-
- async delete(id: string): Promise {
- await api.delete(`/admin/clubs/${id}`);
- }
-};
\ No newline at end of file
diff --git a/admin/src/services/dashboardService.ts b/admin/src/services/dashboardService.ts
deleted file mode 100644
index 3deb42fc..00000000
--- a/admin/src/services/dashboardService.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-// Dashboard service - mock implementation
-// In a real application, this would make API calls to a backend server
-
-interface NavigationCard {
- title: string;
- icon: string;
- description: string;
- color: string;
- iconBg: string;
- path: string;
-}
-
-export interface DashboardData {
- navigationCards: NavigationCard[];
-}
-
-class DashboardService {
- async getDashboardData(): Promise {
- // In a real app, this would be an API call
- // Simulating a network request with a delay
- return new Promise((resolve) => {
- setTimeout(() => {
- resolve({
- navigationCards: [
- {
- title: 'Управление пользователями',
- icon: '👥',
- description: 'Управление учетными записями пользователей',
- color: 'bg-blue-50 border-blue-200',
- iconBg: 'bg-blue-100',
- path: '/users'
- },
- {
- title: 'Турниры',
- icon: '🏆',
- description: 'Управление турнирами и соревнованиями',
- color: 'bg-green-50 border-green-200',
- iconBg: 'bg-green-100',
- path: '/tournaments'
- },
- {
- title: 'Платежи',
- icon: '💰',
- description: 'Просмотр и управление платежами',
- color: 'bg-indigo-50 border-indigo-200',
- iconBg: 'bg-indigo-100',
- path: '/payments'
- },
- {
- title: 'Программа лояльности',
- icon: '🌟',
- description: 'Управление уровнями лояльности',
- color: 'bg-yellow-50 border-yellow-200',
- iconBg: 'bg-yellow-100',
- path: '/loyalty'
- },
- {
- title: 'Администраторы',
- icon: '🔐',
- description: 'Управление правами доступа',
- color: 'bg-purple-50 border-purple-200',
- iconBg: 'bg-purple-100',
- path: '/admins'
- }
- ]
- });
- }, 300);
- });
- }
-}
-
-export const dashboardService = new DashboardService();
\ No newline at end of file
diff --git a/admin/src/services/loyalty.ts b/admin/src/services/loyalty.ts
deleted file mode 100644
index 45b8a9e5..00000000
--- a/admin/src/services/loyalty.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { api } from "../api/api";
-import type { Loyalty } from "../shared/types";
-
-export const loyaltyService = {
- async getAll(): Promise {
- const response = await api.get("/admin/loyalty/");
- return response.data;
- },
-
- async getById(id: number): Promise {
- const response = await api.get(`/admin/loyalty/${id}`);
- return response.data;
- },
-
- async create(loyalty: Loyalty): Promise {
- const response = await api.post("/admin/loyalty/", loyalty);
- return response.data;
- },
-
- async update(loyalty: Loyalty): Promise {
- if (!loyalty.id) {
- throw new Error("Loyalty ID is required for update");
- }
- const response = await api.patch(`/admin/loyalty/${loyalty.id}`, loyalty);
- return response.data;
- },
-
- async delete(id: number): Promise {
- await api.delete(`/admin/loyalty/${id}`);
- }
-};
\ No newline at end of file
diff --git a/admin/src/services/payment.ts b/admin/src/services/payment.ts
deleted file mode 100644
index 4ed9fdc0..00000000
--- a/admin/src/services/payment.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { api } from "../api/api";
-
-export interface Payment {
- id: string;
- payment_id: string;
- date: string;
- amount: number;
- payment_link: string;
- status: string;
- registration?: {
- id: string;
- user_id: string;
- tournament_id: string;
- status: string;
- user?: {
- id: string;
- first_name: string;
- second_name: string;
- city: string;
- username?: string;
- };
- tournament?: {
- id: string;
- name: string;
- };
- };
-}
-
-export interface PaymentResponse {
- payments: Payment[];
- total: number;
-}
-
-const getAll = async (skip = 0, limit = 10, filters: { user_id?: string; tournament_id?: string; status?: string } = {}): Promise => {
- const params: Record = { skip, limit };
- if (filters.user_id) params.user_id = filters.user_id;
- if (filters.tournament_id) params.tournament_id = filters.tournament_id;
- if (filters.status && filters.status !== 'all') params.status = filters.status;
- const response = await api.get("/admin/payments/", { params });
- return response.data;
-};
-
-export const paymentService = {
- getAll,
-};
\ No newline at end of file
diff --git a/admin/src/services/registration.ts b/admin/src/services/registration.ts
deleted file mode 100644
index 4e8de320..00000000
--- a/admin/src/services/registration.ts
+++ /dev/null
@@ -1,64 +0,0 @@
-import { api } from "../api/api";
-
-export interface Registration {
- id: string;
- user_id: string;
- tournament_id: string;
- date: string;
- status: string;
- user: {
- id: string;
- first_name: string;
- second_name: string;
- city: string;
- username?: string;
- };
- tournament: {
- id: string;
- name: string;
- };
- payments: Payment[];
-}
-
-export interface Payment {
- id: string;
- payment_id: string;
- date: string;
- amount: number;
- payment_link: string;
- status: string;
- confirmation_token: string;
-}
-
-export interface RegistrationResponse {
- registrations: Registration[];
- total: number;
-}
-
-const getAll = async (
- skip = 0,
- limit = 10,
- filters: {
- user_id?: string;
- tournament_id?: string;
- status?: string
- } = {}
-): Promise => {
- const params: Record = { skip, limit };
- if (filters.user_id) params.user_id = filters.user_id;
- if (filters.tournament_id) params.tournament_id = filters.tournament_id;
- if (filters.status && filters.status !== 'all') params.status = filters.status;
-
- const response = await api.get("/admin/registrations/", { params });
- return response.data;
-};
-
-const updateStatus = async (registrationId: string, status: string): Promise<{ message: string; status: string }> => {
- const response = await api.patch(`/admin/registrations/${registrationId}/status`, { status });
- return response.data;
-};
-
-export const registrationService = {
- getAll,
- updateStatus,
-};
\ No newline at end of file
diff --git a/admin/src/services/tournament.ts b/admin/src/services/tournament.ts
deleted file mode 100644
index c1248adc..00000000
--- a/admin/src/services/tournament.ts
+++ /dev/null
@@ -1,43 +0,0 @@
-import { api } from "../api/api";
-import type { Tournament, Participant, WaitlistEntry } from "../shared/types";
-import { RegistrationStatus } from "../shared/types";
-
-export const tournamentService = {
- async getAll(): Promise {
- const response = await api.get("/admin/tournaments/");
- return response.data;
- },
-
- async getById(id: number): Promise {
- const response = await api.get(`/admin/tournaments/${id}`);
- return response.data;
- },
-
- async create(tournament: Tournament): Promise {
- const response = await api.post("/admin/tournaments/", tournament);
- return response.data;
- },
-
- async update(tournament: Tournament): Promise {
- const response = await api.patch(`/admin/tournaments/${tournament.id}`, tournament);
- return response.data;
- },
-
- async delete(id: number): Promise {
- await api.delete(`/admin/tournaments/${id}`);
- },
-
- async getParticipants(tournamentId: number): Promise {
- const response = await api.get(`/admin/tournaments/${tournamentId}/participants`);
- return response.data;
- },
-
- async getWaitlist(tournamentId: number): Promise {
- const response = await api.get(`/admin/tournaments/${tournamentId}/waitlist`);
- return response.data;
- },
-
- async updateRegistrationStatus(registrationId: string, status: RegistrationStatus): Promise {
- await api.patch(`/admin/registrations/${registrationId}/status`, { status });
- }
-};
\ No newline at end of file
diff --git a/admin/src/services/user.ts b/admin/src/services/user.ts
deleted file mode 100644
index 89b5a3a1..00000000
--- a/admin/src/services/user.ts
+++ /dev/null
@@ -1,28 +0,0 @@
-import { api } from "../api/api";
-import type { User, UserListItem } from "../shared/types";
-
-export const userService = {
- async getAll(skip = 0, limit = 50): Promise<{users: User[], total: number}> {
- const response = await api.get(`/admin/users/?skip=${skip}&limit=${limit}`);
-
- return {
- users: response.data,
- total: parseInt(response.headers['x-total-count'] || response.data.length.toString())
- };
- },
-
- async getAllUsers(): Promise {
- const response = await api.get(`/admin/users/all`);
- return response.data;
- },
-
- async getById(id: string): Promise {
- const response = await api.get(`/admin/users/${id}`);
- return response.data;
- },
-
- async update(user: Partial): Promise {
- const response = await api.patch(`/admin/users/${user.id}`, user);
- return response.data;
- }
-};
\ No newline at end of file
diff --git a/admin/src/shared/types.ts b/admin/src/shared/types.ts
index 4d107f6e..38a4cda8 100644
--- a/admin/src/shared/types.ts
+++ b/admin/src/shared/types.ts
@@ -1,4 +1,4 @@
-export interface Club {
+export interface Court {
id: string
name: string
address: string
@@ -10,7 +10,7 @@ export interface Tournament {
start_time: string
end_time?: string
price: number
- club_id: string
+ court_id: string
tournament_type: string
rank_min: number
rank_max: number
@@ -91,3 +91,22 @@ export interface UserListItem {
rank: number
is_registered: boolean
}
+
+export interface Club {
+ id: string
+ name: string
+ description?: string
+ createdAt: string
+ userCount?: number
+}
+
+export interface CreateClub {
+ id: string
+ name: string
+ description?: string
+}
+
+export interface PatchClub {
+ name?: string
+ description?: string
+}
diff --git a/admin-new/src/types/admin.ts b/admin/src/types/admin.ts
similarity index 100%
rename from admin-new/src/types/admin.ts
rename to admin/src/types/admin.ts
diff --git a/admin-new/src/types/auth.ts b/admin/src/types/auth.ts
similarity index 100%
rename from admin-new/src/types/auth.ts
rename to admin/src/types/auth.ts
diff --git a/admin-new/src/types/navigation.ts b/admin/src/types/navigation.ts
similarity index 100%
rename from admin-new/src/types/navigation.ts
rename to admin/src/types/navigation.ts
diff --git a/admin-new/src/types/user.ts b/admin/src/types/user.ts
similarity index 100%
rename from admin-new/src/types/user.ts
rename to admin/src/types/user.ts
diff --git a/admin/src/utils/ratingUtils.ts b/admin/src/utils/ratingUtils.ts
index 041d44f7..a0d51549 100644
--- a/admin/src/utils/ratingUtils.ts
+++ b/admin/src/utils/ratingUtils.ts
@@ -89,4 +89,15 @@ export function getPlayingPositionText(position: string): string {
default:
return position;
}
+}
+
+/**
+ * Finds the rating level by its value
+ * @param value Rating value
+ * @returns Rating level object or null if not found
+ */
+export function getRatingLevelByValue(value: number) {
+ return ratingLevels.find(level =>
+ value >= level.min && value <= level.max
+ );
}
\ No newline at end of file
diff --git a/admin/tailwind.config.js b/admin/tailwind.config.js
index da7c9efa..dbad8a5b 100644
--- a/admin/tailwind.config.js
+++ b/admin/tailwind.config.js
@@ -1,11 +1,60 @@
/** @type {import('tailwindcss').Config} */
export default {
- content: [
+ darkMode: ["class"],
+ content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
- extend: {},
+ extend: {
+ borderRadius: {
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)'
+ },
+ colors: {
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ card: {
+ DEFAULT: 'hsl(var(--card))',
+ foreground: 'hsl(var(--card-foreground))'
+ },
+ popover: {
+ DEFAULT: 'hsl(var(--popover))',
+ foreground: 'hsl(var(--popover-foreground))'
+ },
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))'
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))'
+ },
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))'
+ },
+ accent: {
+ DEFAULT: 'hsl(var(--accent))',
+ foreground: 'hsl(var(--accent-foreground))'
+ },
+ destructive: {
+ DEFAULT: 'hsl(var(--destructive))',
+ foreground: 'hsl(var(--destructive-foreground))'
+ },
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ chart: {
+ '1': 'hsl(var(--chart-1))',
+ '2': 'hsl(var(--chart-2))',
+ '3': 'hsl(var(--chart-3))',
+ '4': 'hsl(var(--chart-4))',
+ '5': 'hsl(var(--chart-5))'
+ }
+ }
+ }
},
- plugins: [],
+ plugins: [require("tailwindcss-animate")],
}
\ No newline at end of file
diff --git a/admin/tsconfig.app.json b/admin/tsconfig.app.json
index 9c8a3c40..433ade6a 100644
--- a/admin/tsconfig.app.json
+++ b/admin/tsconfig.app.json
@@ -14,6 +14,12 @@
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
+
+ /* Path mapping */
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
/* Linting */
"strict": true,
diff --git a/admin/tsconfig.json b/admin/tsconfig.json
index 1ffef600..fec8c8e5 100644
--- a/admin/tsconfig.json
+++ b/admin/tsconfig.json
@@ -3,5 +3,11 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
- ]
+ ],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ }
}
diff --git a/admin/tsconfig.node.json b/admin/tsconfig.node.json
index 9728af2d..caa60afa 100644
--- a/admin/tsconfig.node.json
+++ b/admin/tsconfig.node.json
@@ -2,7 +2,7 @@
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
- "lib": ["ES2023"],
+ "lib": ["ES2023", "DOM"],
"module": "ESNext",
"skipLibCheck": true,
@@ -17,7 +17,6 @@
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
- "erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
diff --git a/admin/vite.config.ts b/admin/vite.config.ts
index 8b0f57b9..e17b9ff0 100644
--- a/admin/vite.config.ts
+++ b/admin/vite.config.ts
@@ -1,7 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
+import { fileURLToPath, URL } from 'node:url'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
})
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 00000000..df11a01e
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,222 @@
+{
+ "name": "gopadel",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "dependencies": {
+ "@types/react-datepicker": "^6.2.0",
+ "date-fns": "^4.1.0",
+ "react-datepicker": "^8.4.0",
+ "react-day-picker": "^9.8.0"
+ }
+ },
+ "node_modules/@date-fns/tz": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.2.0.tgz",
+ "integrity": "sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==",
+ "license": "MIT"
+ },
+ "node_modules/@floating-ui/core": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz",
+ "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/dom": {
+ "version": "1.7.2",
+ "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz",
+ "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/core": "^1.7.2",
+ "@floating-ui/utils": "^0.2.10"
+ }
+ },
+ "node_modules/@floating-ui/react": {
+ "version": "0.26.28",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.28.tgz",
+ "integrity": "sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.1.2",
+ "@floating-ui/utils": "^0.2.8",
+ "tabbable": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/react-dom": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz",
+ "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/dom": "^1.7.2"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0",
+ "react-dom": ">=16.8.0"
+ }
+ },
+ "node_modules/@floating-ui/utils": {
+ "version": "0.2.10",
+ "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz",
+ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+ "license": "MIT"
+ },
+ "node_modules/@types/react": {
+ "version": "19.1.8",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
+ "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.0.2"
+ }
+ },
+ "node_modules/@types/react-datepicker": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/@types/react-datepicker/-/react-datepicker-6.2.0.tgz",
+ "integrity": "sha512-+JtO4Fm97WLkJTH8j8/v3Ldh7JCNRwjMYjRaKh4KHH0M3jJoXtwiD3JBCsdlg3tsFIw9eQSqyAPeVDN2H2oM9Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react": "^0.26.2",
+ "@types/react": "*",
+ "date-fns": "^3.3.1"
+ }
+ },
+ "node_modules/@types/react-datepicker/node_modules/date-fns": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz",
+ "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
+ "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
+ "license": "MIT"
+ },
+ "node_modules/date-fns": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
+ "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/kossnocorp"
+ }
+ },
+ "node_modules/date-fns-jalali": {
+ "version": "4.1.0-0",
+ "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
+ "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
+ "license": "MIT"
+ },
+ "node_modules/react": {
+ "version": "19.1.0",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
+ "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-datepicker": {
+ "version": "8.4.0",
+ "resolved": "https://registry.npmjs.org/react-datepicker/-/react-datepicker-8.4.0.tgz",
+ "integrity": "sha512-6nPDnj8vektWCIOy9ArS3avus9Ndsyz5XgFCJ7nBxXASSpBdSL6lG9jzNNmViPOAOPh6T5oJyGaXuMirBLECag==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react": "^0.27.3",
+ "clsx": "^2.1.1",
+ "date-fns": "^4.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc",
+ "react-dom": "^16.9.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
+ }
+ },
+ "node_modules/react-datepicker/node_modules/@floating-ui/react": {
+ "version": "0.27.13",
+ "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.13.tgz",
+ "integrity": "sha512-Qmj6t9TjgWAvbygNEu1hj4dbHI9CY0ziCMIJrmYoDIn9TUAH5lRmiIeZmRd4c6QEZkzdoH7jNnoNyoY1AIESiA==",
+ "license": "MIT",
+ "dependencies": {
+ "@floating-ui/react-dom": "^2.1.4",
+ "@floating-ui/utils": "^0.2.10",
+ "tabbable": "^6.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=17.0.0",
+ "react-dom": ">=17.0.0"
+ }
+ },
+ "node_modules/react-day-picker": {
+ "version": "9.8.0",
+ "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.8.0.tgz",
+ "integrity": "sha512-E0yhhg7R+pdgbl/2toTb0xBhsEAtmAx1l7qjIWYfcxOy8w4rTSVfbtBoSzVVhPwKP/5E9iL38LivzoE3AQDhCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@date-fns/tz": "1.2.0",
+ "date-fns": "4.1.0",
+ "date-fns-jalali": "4.1.0-0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "individual",
+ "url": "https://github.com/sponsors/gpbl"
+ },
+ "peerDependencies": {
+ "react": ">=16.8.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.1.0",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
+ "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "scheduler": "^0.26.0"
+ },
+ "peerDependencies": {
+ "react": "^19.1.0"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.26.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
+ "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/tabbable": {
+ "version": "6.2.0",
+ "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
+ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==",
+ "license": "MIT"
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 00000000..5c278cde
--- /dev/null
+++ b/package.json
@@ -0,0 +1,8 @@
+{
+ "dependencies": {
+ "@types/react-datepicker": "^6.2.0",
+ "date-fns": "^4.1.0",
+ "react-datepicker": "^8.4.0",
+ "react-day-picker": "^9.8.0"
+ }
+}
diff --git a/server-go/docs/docs.go b/server-go/docs/docs.go
index fbee4df2..6c977549 100644
--- a/server-go/docs/docs.go
+++ b/server-go/docs/docs.go
@@ -206,6 +206,227 @@ const docTemplate = `{
}
}
},
+ "/admin/clubs": {
+ "get": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Get all clubs. Available for any admin.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-clubs"
+ ],
+ "summary": "Get all clubs (Admin)",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/domain.Club"
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Create a new club. Available for superusers only.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-clubs"
+ ],
+ "summary": "Create club (Admin)",
+ "parameters": [
+ {
+ "description": "Club data",
+ "name": "club",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/domain.CreateClub"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/domain.Club"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/clubs/{id}": {
+ "delete": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Delete club. Available for superusers only.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-clubs"
+ ],
+ "summary": "Delete club (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Club ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/domain.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ },
+ "patch": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Update club data. Available for superusers only.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-clubs"
+ ],
+ "summary": "Update club (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Club ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Club update data",
+ "name": "club",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/domain.PatchClub"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/domain.Club"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/admin/courts": {
"get": {
"security": [
@@ -855,30 +1076,305 @@ const docTemplate = `{
"BearerAuth": []
}
],
- "description": "Get list of tournaments for filtering. Available for any admin.",
+ "description": "Get list of tournaments for filtering. Available for any admin.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-registrations"
+ ],
+ "summary": "Get tournament options (Admin)",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/registrations/users": {
+ "post": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Get list of users for filtering by telegram username only. Available for any admin.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-registrations"
+ ],
+ "summary": "Get user options (Admin)",
+ "parameters": [
+ {
+ "description": "User filter (telegram username only)",
+ "name": "filter",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "telegramUsername": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "firstName": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "lastName": {
+ "type": "string"
+ },
+ "telegramUsername": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/registrations/{id}": {
+ "get": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Get registration with payments and tournament info. Available for any admin.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-registrations"
+ ],
+ "summary": "Get registration (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Registration ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/domain.RegistrationWithPayments"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/registrations/{id}/status": {
+ "patch": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Update registration status. Available only for superuser.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-registrations"
+ ],
+ "summary": "Update registration status (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Registration ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Status update data",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/domain.RegistrationWithPayments"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/tournaments": {
+ "post": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Create a new tournament. Available for any admin.",
+ "consumes": [
+ "application/json"
+ ],
"produces": [
"application/json"
],
"tags": [
- "admin-registrations"
+ "admin-tournaments"
+ ],
+ "summary": "Create tournament (Admin)",
+ "parameters": [
+ {
+ "description": "Tournament data",
+ "name": "tournament",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/domain.CreateTournament"
+ }
+ }
],
- "summary": "Get tournament options (Admin)",
"responses": {
- "200": {
- "description": "OK",
+ "201": {
+ "description": "Created",
"schema": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string"
- },
- "name": {
- "type": "string"
- }
- }
- }
+ "$ref": "#/definitions/domain.Tournament"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
}
},
"401": {
@@ -896,14 +1392,14 @@ const docTemplate = `{
}
}
},
- "/admin/registrations/users": {
+ "/admin/tournaments/filter": {
"post": {
"security": [
{
"BearerAuth": []
}
],
- "description": "Get list of users for filtering by telegram username only. Available for any admin.",
+ "description": "Get all tournaments with advanced filtering. Available for any admin.",
"consumes": [
"application/json"
],
@@ -911,22 +1407,16 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "admin-registrations"
+ "admin-tournaments"
],
- "summary": "Get user options (Admin)",
+ "summary": "Get all tournaments (Admin)",
"parameters": [
{
- "description": "User filter (telegram username only)",
+ "description": "Filter criteria",
"name": "filter",
"in": "body",
- "required": true,
"schema": {
- "type": "object",
- "properties": {
- "telegramUsername": {
- "type": "string"
- }
- }
+ "$ref": "#/definitions/domain.AdminFilterTournament"
}
}
],
@@ -936,30 +1426,10 @@ const docTemplate = `{
"schema": {
"type": "array",
"items": {
- "type": "object",
- "properties": {
- "firstName": {
- "type": "string"
- },
- "id": {
- "type": "string"
- },
- "lastName": {
- "type": "string"
- },
- "telegramUsername": {
- "type": "string"
- }
- }
+ "$ref": "#/definitions/domain.Tournament"
}
}
},
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/domain.ErrorResponse"
- }
- },
"401": {
"description": "Unauthorized",
"schema": {
@@ -975,25 +1445,25 @@ const docTemplate = `{
}
}
},
- "/admin/registrations/{id}": {
- "get": {
+ "/admin/tournaments/{id}": {
+ "delete": {
"security": [
{
"BearerAuth": []
}
],
- "description": "Get registration with payments and tournament info. Available for any admin.",
+ "description": "Delete tournament. Available for any admin.",
"produces": [
"application/json"
],
"tags": [
- "admin-registrations"
+ "admin-tournaments"
],
- "summary": "Get registration (Admin)",
+ "summary": "Delete tournament (Admin)",
"parameters": [
{
"type": "string",
- "description": "Registration ID",
+ "description": "Tournament ID",
"name": "id",
"in": "path",
"required": true
@@ -1003,7 +1473,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/domain.RegistrationWithPayments"
+ "$ref": "#/definitions/domain.MessageResponse"
}
},
"400": {
@@ -1031,16 +1501,14 @@ const docTemplate = `{
}
}
}
- }
- },
- "/admin/registrations/{id}/status": {
+ },
"patch": {
"security": [
{
"BearerAuth": []
}
],
- "description": "Update registration status. Available only for superuser.",
+ "description": "Update tournament data. Available for any admin.",
"consumes": [
"application/json"
],
@@ -1048,29 +1516,24 @@ const docTemplate = `{
"application/json"
],
"tags": [
- "admin-registrations"
+ "admin-tournaments"
],
- "summary": "Update registration status (Admin)",
+ "summary": "Update tournament (Admin)",
"parameters": [
{
"type": "string",
- "description": "Registration ID",
+ "description": "Tournament ID",
"name": "id",
"in": "path",
"required": true
},
{
- "description": "Status update data",
- "name": "body",
+ "description": "Tournament update data",
+ "name": "tournament",
"in": "body",
"required": true,
"schema": {
- "type": "object",
- "properties": {
- "status": {
- "type": "string"
- }
- }
+ "$ref": "#/definitions/domain.AdminPatchTournament"
}
}
],
@@ -1078,7 +1541,7 @@ const docTemplate = `{
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/domain.RegistrationWithPayments"
+ "$ref": "#/definitions/domain.Tournament"
}
},
"400": {
@@ -1093,12 +1556,6 @@ const docTemplate = `{
"$ref": "#/definitions/domain.ErrorResponse"
}
},
- "403": {
- "description": "Forbidden",
- "schema": {
- "$ref": "#/definitions/domain.ErrorResponse"
- }
- },
"404": {
"description": "Not Found",
"schema": {
@@ -1250,6 +1707,64 @@ const docTemplate = `{
}
}
},
+ "/admin/waitlist/tournament/{tournamentId}": {
+ "get": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Get waitlist users for a specific tournament. Available for any admin. Read-only access.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-waitlist"
+ ],
+ "summary": "Get tournament waitlist (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Tournament ID",
+ "name": "tournamentId",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/domain.WaitlistUser"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/admin/{id}": {
"delete": {
"security": [
@@ -1783,7 +2298,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
- "description": "Creates new registration or updates existing CANCELED status to PENDING",
+ "description": "Creates new registration or updates existing CANCELED status to PENDING. For free tournaments, status is set to ACTIVE immediately.",
"consumes": [
"application/json"
],
@@ -2011,7 +2526,7 @@ const docTemplate = `{
"ApiKeyAuth": []
}
],
- "description": "Creates payment in YooKassa for existing PENDING registration. Returns existing payment if it's already in success/pending status.",
+ "description": "Creates payment in YooKassa for existing PENDING registration. Returns existing payment if it's already in success/pending status. For free tournaments (price = 0), payment is not required.",
"consumes": [
"application/json"
],
@@ -2048,7 +2563,7 @@ const docTemplate = `{
}
},
"400": {
- "description": "Invalid request data",
+ "description": "Invalid request data or tournament is free",
"schema": {
"type": "object",
"additionalProperties": {
@@ -2996,6 +3511,36 @@ const docTemplate = `{
}
}
},
+ "domain.AdminFilterTournament": {
+ "type": "object",
+ "properties": {
+ "clubId": {
+ "type": "string"
+ },
+ "clubName": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "organizatorFirstName": {
+ "type": "string"
+ },
+ "organizatorId": {
+ "type": "string"
+ },
+ "organizatorTelegramId": {
+ "description": "Дополнительные поля для удобства фильтрации",
+ "type": "integer"
+ },
+ "tournamentType": {
+ "type": "string"
+ }
+ }
+ },
"domain.AdminLogin": {
"type": "object",
"required": [
@@ -3046,6 +3591,47 @@ const docTemplate = `{
}
}
},
+ "domain.AdminPatchTournament": {
+ "type": "object",
+ "properties": {
+ "clubId": {
+ "type": "string"
+ },
+ "courtId": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "endTime": {
+ "type": "string"
+ },
+ "maxUsers": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "organizatorId": {
+ "type": "string"
+ },
+ "price": {
+ "type": "integer"
+ },
+ "rankMax": {
+ "type": "number"
+ },
+ "rankMin": {
+ "type": "number"
+ },
+ "startTime": {
+ "type": "string"
+ },
+ "tournamentType": {
+ "type": "string"
+ }
+ }
+ },
"domain.AdminPatchUser": {
"type": "object",
"properties": {
@@ -3188,6 +3774,24 @@ const docTemplate = `{
}
}
},
+ "domain.CreateClub": {
+ "type": "object",
+ "required": [
+ "id",
+ "name"
+ ],
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"domain.CreateCourt": {
"type": "object",
"required": [
@@ -3228,8 +3832,6 @@ const docTemplate = `{
"maxUsers",
"name",
"organizatorId",
- "rankMax",
- "rankMin",
"startTime",
"tournamentType"
],
@@ -3259,10 +3861,12 @@ const docTemplate = `{
"type": "integer"
},
"rankMax": {
- "type": "number"
+ "type": "number",
+ "minimum": 0
},
"rankMin": {
- "type": "number"
+ "type": "number",
+ "minimum": 0
},
"startTime": {
"type": "string"
@@ -3420,6 +4024,17 @@ const docTemplate = `{
}
}
},
+ "domain.PatchClub": {
+ "type": "object",
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"domain.PatchCourt": {
"type": "object",
"properties": {
diff --git a/server-go/docs/swagger.json b/server-go/docs/swagger.json
index 5fadfba3..67ddb4ff 100644
--- a/server-go/docs/swagger.json
+++ b/server-go/docs/swagger.json
@@ -198,6 +198,227 @@
}
}
},
+ "/admin/clubs": {
+ "get": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Get all clubs. Available for any admin.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-clubs"
+ ],
+ "summary": "Get all clubs (Admin)",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/domain.Club"
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ },
+ "post": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Create a new club. Available for superusers only.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-clubs"
+ ],
+ "summary": "Create club (Admin)",
+ "parameters": [
+ {
+ "description": "Club data",
+ "name": "club",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/domain.CreateClub"
+ }
+ }
+ ],
+ "responses": {
+ "201": {
+ "description": "Created",
+ "schema": {
+ "$ref": "#/definitions/domain.Club"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/clubs/{id}": {
+ "delete": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Delete club. Available for superusers only.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-clubs"
+ ],
+ "summary": "Delete club (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Club ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/domain.MessageResponse"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ },
+ "patch": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Update club data. Available for superusers only.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-clubs"
+ ],
+ "summary": "Update club (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Club ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Club update data",
+ "name": "club",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/domain.PatchClub"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/domain.Club"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/admin/courts": {
"get": {
"security": [
@@ -847,30 +1068,305 @@
"BearerAuth": []
}
],
- "description": "Get list of tournaments for filtering. Available for any admin.",
+ "description": "Get list of tournaments for filtering. Available for any admin.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-registrations"
+ ],
+ "summary": "Get tournament options (Admin)",
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/registrations/users": {
+ "post": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Get list of users for filtering by telegram username only. Available for any admin.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-registrations"
+ ],
+ "summary": "Get user options (Admin)",
+ "parameters": [
+ {
+ "description": "User filter (telegram username only)",
+ "name": "filter",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "telegramUsername": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "type": "object",
+ "properties": {
+ "firstName": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "lastName": {
+ "type": "string"
+ },
+ "telegramUsername": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/registrations/{id}": {
+ "get": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Get registration with payments and tournament info. Available for any admin.",
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-registrations"
+ ],
+ "summary": "Get registration (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Registration ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/domain.RegistrationWithPayments"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/registrations/{id}/status": {
+ "patch": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Update registration status. Available only for superuser.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-registrations"
+ ],
+ "summary": "Update registration status (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Registration ID",
+ "name": "id",
+ "in": "path",
+ "required": true
+ },
+ {
+ "description": "Status update data",
+ "name": "body",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "type": "object",
+ "properties": {
+ "status": {
+ "type": "string"
+ }
+ }
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "$ref": "#/definitions/domain.RegistrationWithPayments"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "403": {
+ "description": "Forbidden",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "404": {
+ "description": "Not Found",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
+ "/admin/tournaments": {
+ "post": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Create a new tournament. Available for any admin.",
+ "consumes": [
+ "application/json"
+ ],
"produces": [
"application/json"
],
"tags": [
- "admin-registrations"
+ "admin-tournaments"
+ ],
+ "summary": "Create tournament (Admin)",
+ "parameters": [
+ {
+ "description": "Tournament data",
+ "name": "tournament",
+ "in": "body",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/domain.CreateTournament"
+ }
+ }
],
- "summary": "Get tournament options (Admin)",
"responses": {
- "200": {
- "description": "OK",
+ "201": {
+ "description": "Created",
"schema": {
- "type": "array",
- "items": {
- "type": "object",
- "properties": {
- "id": {
- "type": "string"
- },
- "name": {
- "type": "string"
- }
- }
- }
+ "$ref": "#/definitions/domain.Tournament"
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
}
},
"401": {
@@ -888,14 +1384,14 @@
}
}
},
- "/admin/registrations/users": {
+ "/admin/tournaments/filter": {
"post": {
"security": [
{
"BearerAuth": []
}
],
- "description": "Get list of users for filtering by telegram username only. Available for any admin.",
+ "description": "Get all tournaments with advanced filtering. Available for any admin.",
"consumes": [
"application/json"
],
@@ -903,22 +1399,16 @@
"application/json"
],
"tags": [
- "admin-registrations"
+ "admin-tournaments"
],
- "summary": "Get user options (Admin)",
+ "summary": "Get all tournaments (Admin)",
"parameters": [
{
- "description": "User filter (telegram username only)",
+ "description": "Filter criteria",
"name": "filter",
"in": "body",
- "required": true,
"schema": {
- "type": "object",
- "properties": {
- "telegramUsername": {
- "type": "string"
- }
- }
+ "$ref": "#/definitions/domain.AdminFilterTournament"
}
}
],
@@ -928,30 +1418,10 @@
"schema": {
"type": "array",
"items": {
- "type": "object",
- "properties": {
- "firstName": {
- "type": "string"
- },
- "id": {
- "type": "string"
- },
- "lastName": {
- "type": "string"
- },
- "telegramUsername": {
- "type": "string"
- }
- }
+ "$ref": "#/definitions/domain.Tournament"
}
}
},
- "400": {
- "description": "Bad Request",
- "schema": {
- "$ref": "#/definitions/domain.ErrorResponse"
- }
- },
"401": {
"description": "Unauthorized",
"schema": {
@@ -967,25 +1437,25 @@
}
}
},
- "/admin/registrations/{id}": {
- "get": {
+ "/admin/tournaments/{id}": {
+ "delete": {
"security": [
{
"BearerAuth": []
}
],
- "description": "Get registration with payments and tournament info. Available for any admin.",
+ "description": "Delete tournament. Available for any admin.",
"produces": [
"application/json"
],
"tags": [
- "admin-registrations"
+ "admin-tournaments"
],
- "summary": "Get registration (Admin)",
+ "summary": "Delete tournament (Admin)",
"parameters": [
{
"type": "string",
- "description": "Registration ID",
+ "description": "Tournament ID",
"name": "id",
"in": "path",
"required": true
@@ -995,7 +1465,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/domain.RegistrationWithPayments"
+ "$ref": "#/definitions/domain.MessageResponse"
}
},
"400": {
@@ -1023,16 +1493,14 @@
}
}
}
- }
- },
- "/admin/registrations/{id}/status": {
+ },
"patch": {
"security": [
{
"BearerAuth": []
}
],
- "description": "Update registration status. Available only for superuser.",
+ "description": "Update tournament data. Available for any admin.",
"consumes": [
"application/json"
],
@@ -1040,29 +1508,24 @@
"application/json"
],
"tags": [
- "admin-registrations"
+ "admin-tournaments"
],
- "summary": "Update registration status (Admin)",
+ "summary": "Update tournament (Admin)",
"parameters": [
{
"type": "string",
- "description": "Registration ID",
+ "description": "Tournament ID",
"name": "id",
"in": "path",
"required": true
},
{
- "description": "Status update data",
- "name": "body",
+ "description": "Tournament update data",
+ "name": "tournament",
"in": "body",
"required": true,
"schema": {
- "type": "object",
- "properties": {
- "status": {
- "type": "string"
- }
- }
+ "$ref": "#/definitions/domain.AdminPatchTournament"
}
}
],
@@ -1070,7 +1533,7 @@
"200": {
"description": "OK",
"schema": {
- "$ref": "#/definitions/domain.RegistrationWithPayments"
+ "$ref": "#/definitions/domain.Tournament"
}
},
"400": {
@@ -1085,12 +1548,6 @@
"$ref": "#/definitions/domain.ErrorResponse"
}
},
- "403": {
- "description": "Forbidden",
- "schema": {
- "$ref": "#/definitions/domain.ErrorResponse"
- }
- },
"404": {
"description": "Not Found",
"schema": {
@@ -1242,6 +1699,64 @@
}
}
},
+ "/admin/waitlist/tournament/{tournamentId}": {
+ "get": {
+ "security": [
+ {
+ "BearerAuth": []
+ }
+ ],
+ "description": "Get waitlist users for a specific tournament. Available for any admin. Read-only access.",
+ "consumes": [
+ "application/json"
+ ],
+ "produces": [
+ "application/json"
+ ],
+ "tags": [
+ "admin-waitlist"
+ ],
+ "summary": "Get tournament waitlist (Admin)",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "Tournament ID",
+ "name": "tournamentId",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "OK",
+ "schema": {
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/domain.WaitlistUser"
+ }
+ }
+ },
+ "400": {
+ "description": "Bad Request",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "401": {
+ "description": "Unauthorized",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ },
+ "500": {
+ "description": "Internal Server Error",
+ "schema": {
+ "$ref": "#/definitions/domain.ErrorResponse"
+ }
+ }
+ }
+ }
+ },
"/admin/{id}": {
"delete": {
"security": [
@@ -1775,7 +2290,7 @@
"ApiKeyAuth": []
}
],
- "description": "Creates new registration or updates existing CANCELED status to PENDING",
+ "description": "Creates new registration or updates existing CANCELED status to PENDING. For free tournaments, status is set to ACTIVE immediately.",
"consumes": [
"application/json"
],
@@ -2003,7 +2518,7 @@
"ApiKeyAuth": []
}
],
- "description": "Creates payment in YooKassa for existing PENDING registration. Returns existing payment if it's already in success/pending status.",
+ "description": "Creates payment in YooKassa for existing PENDING registration. Returns existing payment if it's already in success/pending status. For free tournaments (price = 0), payment is not required.",
"consumes": [
"application/json"
],
@@ -2040,7 +2555,7 @@
}
},
"400": {
- "description": "Invalid request data",
+ "description": "Invalid request data or tournament is free",
"schema": {
"type": "object",
"additionalProperties": {
@@ -2988,6 +3503,36 @@
}
}
},
+ "domain.AdminFilterTournament": {
+ "type": "object",
+ "properties": {
+ "clubId": {
+ "type": "string"
+ },
+ "clubName": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ },
+ "organizatorFirstName": {
+ "type": "string"
+ },
+ "organizatorId": {
+ "type": "string"
+ },
+ "organizatorTelegramId": {
+ "description": "Дополнительные поля для удобства фильтрации",
+ "type": "integer"
+ },
+ "tournamentType": {
+ "type": "string"
+ }
+ }
+ },
"domain.AdminLogin": {
"type": "object",
"required": [
@@ -3038,6 +3583,47 @@
}
}
},
+ "domain.AdminPatchTournament": {
+ "type": "object",
+ "properties": {
+ "clubId": {
+ "type": "string"
+ },
+ "courtId": {
+ "type": "string"
+ },
+ "description": {
+ "type": "string"
+ },
+ "endTime": {
+ "type": "string"
+ },
+ "maxUsers": {
+ "type": "integer"
+ },
+ "name": {
+ "type": "string"
+ },
+ "organizatorId": {
+ "type": "string"
+ },
+ "price": {
+ "type": "integer"
+ },
+ "rankMax": {
+ "type": "number"
+ },
+ "rankMin": {
+ "type": "number"
+ },
+ "startTime": {
+ "type": "string"
+ },
+ "tournamentType": {
+ "type": "string"
+ }
+ }
+ },
"domain.AdminPatchUser": {
"type": "object",
"properties": {
@@ -3180,6 +3766,24 @@
}
}
},
+ "domain.CreateClub": {
+ "type": "object",
+ "required": [
+ "id",
+ "name"
+ ],
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"domain.CreateCourt": {
"type": "object",
"required": [
@@ -3220,8 +3824,6 @@
"maxUsers",
"name",
"organizatorId",
- "rankMax",
- "rankMin",
"startTime",
"tournamentType"
],
@@ -3251,10 +3853,12 @@
"type": "integer"
},
"rankMax": {
- "type": "number"
+ "type": "number",
+ "minimum": 0
},
"rankMin": {
- "type": "number"
+ "type": "number",
+ "minimum": 0
},
"startTime": {
"type": "string"
@@ -3412,6 +4016,17 @@
}
}
},
+ "domain.PatchClub": {
+ "type": "object",
+ "properties": {
+ "description": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string"
+ }
+ }
+ },
"domain.PatchCourt": {
"type": "object",
"properties": {
diff --git a/server-go/docs/swagger.yaml b/server-go/docs/swagger.yaml
index 15bc1a2d..ee5c6e53 100644
--- a/server-go/docs/swagger.yaml
+++ b/server-go/docs/swagger.yaml
@@ -19,6 +19,26 @@ definitions:
userTelegramUsername:
type: string
type: object
+ domain.AdminFilterTournament:
+ properties:
+ clubId:
+ type: string
+ clubName:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ organizatorFirstName:
+ type: string
+ organizatorId:
+ type: string
+ organizatorTelegramId:
+ description: Дополнительные поля для удобства фильтрации
+ type: integer
+ tournamentType:
+ type: string
+ type: object
domain.AdminLogin:
properties:
password:
@@ -52,6 +72,33 @@ definitions:
- new_password
- old_password
type: object
+ domain.AdminPatchTournament:
+ properties:
+ clubId:
+ type: string
+ courtId:
+ type: string
+ description:
+ type: string
+ endTime:
+ type: string
+ maxUsers:
+ type: integer
+ name:
+ type: string
+ organizatorId:
+ type: string
+ price:
+ type: integer
+ rankMax:
+ type: number
+ rankMin:
+ type: number
+ startTime:
+ type: string
+ tournamentType:
+ type: string
+ type: object
domain.AdminPatchUser:
properties:
avatar:
@@ -146,6 +193,18 @@ definitions:
- user_id
- username
type: object
+ domain.CreateClub:
+ properties:
+ description:
+ type: string
+ id:
+ type: string
+ name:
+ type: string
+ required:
+ - id
+ - name
+ type: object
domain.CreateCourt:
properties:
address:
@@ -186,8 +245,10 @@ definitions:
price:
type: integer
rankMax:
+ minimum: 0
type: number
rankMin:
+ minimum: 0
type: number
startTime:
type: string
@@ -199,8 +260,6 @@ definitions:
- maxUsers
- name
- organizatorId
- - rankMax
- - rankMin
- startTime
- tournamentType
type: object
@@ -301,6 +360,13 @@ definitions:
username:
type: string
type: object
+ domain.PatchClub:
+ properties:
+ description:
+ type: string
+ name:
+ type: string
+ type: object
domain.PatchCourt:
properties:
address:
@@ -825,6 +891,147 @@ paths:
summary: Get current admin info
tags:
- admin-auth
+ /admin/clubs:
+ get:
+ description: Get all clubs. Available for any admin.
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/domain.Club'
+ type: array
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - BearerAuth: []
+ summary: Get all clubs (Admin)
+ tags:
+ - admin-clubs
+ post:
+ consumes:
+ - application/json
+ description: Create a new club. Available for superusers only.
+ parameters:
+ - description: Club data
+ in: body
+ name: club
+ required: true
+ schema:
+ $ref: '#/definitions/domain.CreateClub'
+ produces:
+ - application/json
+ responses:
+ "201":
+ description: Created
+ schema:
+ $ref: '#/definitions/domain.Club'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - BearerAuth: []
+ summary: Create club (Admin)
+ tags:
+ - admin-clubs
+ /admin/clubs/{id}:
+ delete:
+ description: Delete club. Available for superusers only.
+ parameters:
+ - description: Club ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/domain.MessageResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - BearerAuth: []
+ summary: Delete club (Admin)
+ tags:
+ - admin-clubs
+ patch:
+ consumes:
+ - application/json
+ description: Update club data. Available for superusers only.
+ parameters:
+ - description: Club ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Club update data
+ in: body
+ name: club
+ required: true
+ schema:
+ $ref: '#/definitions/domain.PatchClub'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/domain.Club'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - BearerAuth: []
+ summary: Update club (Admin)
+ tags:
+ - admin-clubs
/admin/courts:
get:
description: Get all courts. Available for any admin.
@@ -1407,6 +1614,157 @@ paths:
summary: Get user options (Admin)
tags:
- admin-registrations
+ /admin/tournaments:
+ post:
+ consumes:
+ - application/json
+ description: Create a new tournament. Available for any admin.
+ parameters:
+ - description: Tournament data
+ in: body
+ name: tournament
+ required: true
+ schema:
+ $ref: '#/definitions/domain.CreateTournament'
+ produces:
+ - application/json
+ responses:
+ "201":
+ description: Created
+ schema:
+ $ref: '#/definitions/domain.Tournament'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - BearerAuth: []
+ summary: Create tournament (Admin)
+ tags:
+ - admin-tournaments
+ /admin/tournaments/{id}:
+ delete:
+ description: Delete tournament. Available for any admin.
+ parameters:
+ - description: Tournament ID
+ in: path
+ name: id
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/domain.MessageResponse'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - BearerAuth: []
+ summary: Delete tournament (Admin)
+ tags:
+ - admin-tournaments
+ patch:
+ consumes:
+ - application/json
+ description: Update tournament data. Available for any admin.
+ parameters:
+ - description: Tournament ID
+ in: path
+ name: id
+ required: true
+ type: string
+ - description: Tournament update data
+ in: body
+ name: tournament
+ required: true
+ schema:
+ $ref: '#/definitions/domain.AdminPatchTournament'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ $ref: '#/definitions/domain.Tournament'
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "404":
+ description: Not Found
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - BearerAuth: []
+ summary: Update tournament (Admin)
+ tags:
+ - admin-tournaments
+ /admin/tournaments/filter:
+ post:
+ consumes:
+ - application/json
+ description: Get all tournaments with advanced filtering. Available for any
+ admin.
+ parameters:
+ - description: Filter criteria
+ in: body
+ name: filter
+ schema:
+ $ref: '#/definitions/domain.AdminFilterTournament'
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/domain.Tournament'
+ type: array
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - BearerAuth: []
+ summary: Get all tournaments (Admin)
+ tags:
+ - admin-tournaments
/admin/users/{id}:
patch:
consumes:
@@ -1494,6 +1852,44 @@ paths:
summary: Filter users (Admin)
tags:
- admin-users
+ /admin/waitlist/tournament/{tournamentId}:
+ get:
+ consumes:
+ - application/json
+ description: Get waitlist users for a specific tournament. Available for any
+ admin. Read-only access.
+ parameters:
+ - description: Tournament ID
+ in: path
+ name: tournamentId
+ required: true
+ type: string
+ produces:
+ - application/json
+ responses:
+ "200":
+ description: OK
+ schema:
+ items:
+ $ref: '#/definitions/domain.WaitlistUser'
+ type: array
+ "400":
+ description: Bad Request
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "401":
+ description: Unauthorized
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ "500":
+ description: Internal Server Error
+ schema:
+ $ref: '#/definitions/domain.ErrorResponse'
+ security:
+ - BearerAuth: []
+ summary: Get tournament waitlist (Admin)
+ tags:
+ - admin-waitlist
/api/v1/yookassa_webhook:
post:
consumes:
@@ -1711,7 +2107,7 @@ paths:
consumes:
- application/json
description: Creates new registration or updates existing CANCELED status to
- PENDING
+ PENDING. For free tournaments, status is set to ACTIVE immediately.
parameters:
- description: Tournament ID
in: path
@@ -1859,7 +2255,8 @@ paths:
consumes:
- application/json
description: Creates payment in YooKassa for existing PENDING registration.
- Returns existing payment if it's already in success/pending status.
+ Returns existing payment if it's already in success/pending status. For free
+ tournaments (price = 0), payment is not required.
parameters:
- description: Tournament ID
in: path
@@ -1880,7 +2277,7 @@ paths:
schema:
$ref: '#/definitions/domain.Payment'
"400":
- description: Invalid request data
+ description: Invalid request data or tournament is free
schema:
additionalProperties:
type: string
diff --git a/server-go/pkg/domain/tournament.go b/server-go/pkg/domain/tournament.go
index 93efa174..520ce79c 100644
--- a/server-go/pkg/domain/tournament.go
+++ b/server-go/pkg/domain/tournament.go
@@ -24,8 +24,8 @@ type CreateTournament struct {
StartTime time.Time `json:"startTime" binding:"required"`
EndTime time.Time `json:"endTime"`
Price int `json:"price"`
- RankMin float64 `json:"rankMin" binding:"required"`
- RankMax float64 `json:"rankMax" binding:"required"`
+ RankMin float64 `json:"rankMin" binding:"min=0"`
+ RankMax float64 `json:"rankMax" binding:"min=0"`
MaxUsers int `json:"maxUsers" binding:"required"`
Description string `json:"description"`
CourtID string `json:"courtId" binding:"required"`
@@ -55,4 +55,33 @@ type FilterTournament struct {
NotEnded *bool `json:"notEnded,omitempty"` // default true
OrganizatorID *string `json:"organizatorId,omitempty"`
FilterByUserClubs *string `json:"filterByUserClubs,omitempty"` // user ID to filter by user's clubs
+}
+
+// AdminPatchTournament структура для админского обновления турнира
+type AdminPatchTournament struct {
+ Name *string `json:"name,omitempty"`
+ StartTime *time.Time `json:"startTime,omitempty"`
+ EndTime *time.Time `json:"endTime,omitempty"`
+ Price *int `json:"price,omitempty"`
+ RankMin *float64 `json:"rankMin,omitempty"`
+ RankMax *float64 `json:"rankMax,omitempty"`
+ MaxUsers *int `json:"maxUsers,omitempty"`
+ Description *string `json:"description,omitempty"`
+ CourtID *string `json:"courtId,omitempty"`
+ ClubID *string `json:"clubId,omitempty"`
+ TournamentType *string `json:"tournamentType,omitempty"`
+ OrganizatorID *string `json:"organizatorId,omitempty"`
+}
+
+// AdminFilterTournament структура для админской фильтрации турниров
+type AdminFilterTournament struct {
+ ID *string `json:"id,omitempty"`
+ Name *string `json:"name,omitempty"`
+ ClubID *string `json:"clubId,omitempty"`
+ OrganizatorID *string `json:"organizatorId,omitempty"`
+ TournamentType *string `json:"tournamentType,omitempty"`
+ // Дополнительные поля для удобства фильтрации
+ OrganizatorTelegramID *int64 `json:"organizatorTelegramId,omitempty"`
+ OrganizatorFirstName *string `json:"organizatorFirstName,omitempty"`
+ ClubName *string `json:"clubName,omitempty"`
}
\ No newline at end of file
diff --git a/server-go/pkg/gateways/rest/admin_clubs/handlers.go b/server-go/pkg/gateways/rest/admin_clubs/handlers.go
new file mode 100644
index 00000000..4ba51be3
--- /dev/null
+++ b/server-go/pkg/gateways/rest/admin_clubs/handlers.go
@@ -0,0 +1,175 @@
+package admin_clubs
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/shampsdev/go-telegram-template/pkg/domain"
+ "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/ginerr"
+ "github.com/shampsdev/go-telegram-template/pkg/usecase"
+)
+
+type Handler struct {
+ clubCase *usecase.Club
+}
+
+func NewHandler(clubCase *usecase.Club) *Handler {
+ return &Handler{
+ clubCase: clubCase,
+ }
+}
+
+// GetAllClubs получает все клубы для админов
+// @Summary Get all clubs (Admin)
+// @Description Get all clubs. Available for any admin.
+// @Tags admin-clubs
+// @Produce json
+// @Security BearerAuth
+// @Success 200 {array} domain.Club
+// @Failure 401 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /admin/clubs [get]
+func (h *Handler) GetAllClubs(c *gin.Context) {
+ filter := &domain.FilterClub{}
+ ctx := &usecase.Context{Context: c, User: nil}
+ clubs, err := h.clubCase.AdminFilter(ctx, filter)
+ if ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to get clubs") {
+ return
+ }
+
+ if clubs == nil {
+ clubs = []*domain.Club{}
+ }
+
+ c.JSON(http.StatusOK, clubs)
+}
+
+// CreateClub создает новый клуб
+// @Summary Create club (Admin)
+// @Description Create a new club. Available for superusers only.
+// @Tags admin-clubs
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Param club body domain.CreateClub true "Club data"
+// @Success 201 {object} domain.Club
+// @Failure 400 {object} domain.ErrorResponse
+// @Failure 401 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /admin/clubs [post]
+func (h *Handler) CreateClub(c *gin.Context) {
+ var createData domain.CreateClub
+ if err := c.ShouldBindJSON(&createData); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx := &usecase.Context{Context: c, User: nil}
+ err := h.clubCase.AdminCreate(ctx, &createData)
+ if ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to create club") {
+ return
+ }
+
+ // Возвращаем созданный клуб
+ filter := &domain.FilterClub{ID: &createData.ID}
+ clubs, err := h.clubCase.AdminFilter(ctx, filter)
+ if ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to get created club") {
+ return
+ }
+
+ if len(clubs) == 0 {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "Created club not found"})
+ return
+ }
+
+ c.JSON(http.StatusCreated, clubs[0])
+}
+
+// PatchClub обновляет клуб
+// @Summary Update club (Admin)
+// @Description Update club data. Available for superusers only.
+// @Tags admin-clubs
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Param id path string true "Club ID"
+// @Param club body domain.PatchClub true "Club update data"
+// @Success 200 {object} domain.Club
+// @Failure 400 {object} domain.ErrorResponse
+// @Failure 401 {object} domain.ErrorResponse
+// @Failure 404 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /admin/clubs/{id} [patch]
+func (h *Handler) PatchClub(c *gin.Context) {
+ clubID := c.Param("id")
+ if clubID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Club ID is required"})
+ return
+ }
+
+ var patchData domain.PatchClub
+ if err := c.ShouldBindJSON(&patchData); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx := &usecase.Context{Context: c, User: nil}
+ err := h.clubCase.Patch(ctx, clubID, &patchData)
+ if err != nil {
+ if err.Error() == fmt.Sprintf("club with id %s not found", clubID) {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to update club")
+ return
+ }
+
+ // Возвращаем обновленный клуб
+ filter := &domain.FilterClub{ID: &clubID}
+ clubs, err := h.clubCase.AdminFilter(ctx, filter)
+ if ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to get updated club") {
+ return
+ }
+
+ if len(clubs) == 0 {
+ c.JSON(http.StatusNotFound, gin.H{"error": "Updated club not found"})
+ return
+ }
+
+ c.JSON(http.StatusOK, clubs[0])
+}
+
+// DeleteClub удаляет клуб
+// @Summary Delete club (Admin)
+// @Description Delete club. Available for superusers only.
+// @Tags admin-clubs
+// @Produce json
+// @Security BearerAuth
+// @Param id path string true "Club ID"
+// @Success 200 {object} domain.MessageResponse
+// @Failure 400 {object} domain.ErrorResponse
+// @Failure 401 {object} domain.ErrorResponse
+// @Failure 404 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /admin/clubs/{id} [delete]
+func (h *Handler) DeleteClub(c *gin.Context) {
+ clubID := c.Param("id")
+ if clubID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Club ID is required"})
+ return
+ }
+
+ ctx := &usecase.Context{Context: c, User: nil}
+ err := h.clubCase.Delete(ctx, clubID)
+ if err != nil {
+ if err.Error() == fmt.Sprintf("club with id %s not found", clubID) {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to delete club")
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Club deleted successfully"})
+}
diff --git a/server-go/pkg/gateways/rest/admin_clubs/setup.go b/server-go/pkg/gateways/rest/admin_clubs/setup.go
new file mode 100644
index 00000000..06a027a5
--- /dev/null
+++ b/server-go/pkg/gateways/rest/admin_clubs/setup.go
@@ -0,0 +1,33 @@
+package admin_clubs
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/middlewares"
+ "github.com/shampsdev/go-telegram-template/pkg/usecase"
+)
+
+func Setup(r *gin.RouterGroup, useCases usecase.Cases) {
+ handler := NewHandler(useCases.Club)
+
+ adminClubsGroup := r.Group("/admin/clubs")
+ {
+ // Все эндпоинты требуют JWT авторизации
+ adminClubsGroup.Use(middlewares.RequireAdminJWT(useCases.AdminUser))
+
+ // GET /admin/clubs - получить все клубы (любой админ)
+ adminClubsGroup.GET("", handler.GetAllClubs)
+
+ // Эндпоинты для изменения данных требуют права суперпользователя
+ superUserGroup := adminClubsGroup.Group("")
+ superUserGroup.Use(middlewares.RequireAdminSuperuser())
+
+ // POST /admin/clubs - создать новый клуб (только суперпользователь)
+ superUserGroup.POST("", handler.CreateClub)
+
+ // PATCH /admin/clubs/:id - обновить клуб (только суперпользователь)
+ superUserGroup.PATCH("/:id", handler.PatchClub)
+
+ // DELETE /admin/clubs/:id - удалить клуб (только суперпользователь)
+ superUserGroup.DELETE("/:id", handler.DeleteClub)
+ }
+}
diff --git a/server-go/pkg/gateways/rest/admin_tournaments/handlers.go b/server-go/pkg/gateways/rest/admin_tournaments/handlers.go
new file mode 100644
index 00000000..0ab43f7d
--- /dev/null
+++ b/server-go/pkg/gateways/rest/admin_tournaments/handlers.go
@@ -0,0 +1,158 @@
+package admin_tournaments
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/shampsdev/go-telegram-template/pkg/domain"
+ "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/ginerr"
+ "github.com/shampsdev/go-telegram-template/pkg/usecase"
+)
+
+type Handler struct {
+ tournamentCase *usecase.Tournament
+}
+
+func NewHandler(tournamentCase *usecase.Tournament) *Handler {
+ return &Handler{
+ tournamentCase: tournamentCase,
+ }
+}
+
+// GetAllTournaments получает все турниры для админов
+// @Summary Get all tournaments (Admin)
+// @Description Get all tournaments with advanced filtering. Available for any admin.
+// @Tags admin-tournaments
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Param filter body domain.AdminFilterTournament false "Filter criteria"
+// @Success 200 {array} domain.Tournament
+// @Failure 401 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /admin/tournaments/filter [post]
+func (h *Handler) GetAllTournaments(c *gin.Context) {
+ var filter domain.AdminFilterTournament
+ if err := c.ShouldBindJSON(&filter); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx := &usecase.Context{Context: c, User: nil}
+ tournaments, err := h.tournamentCase.AdminFilter(ctx, &filter)
+ if ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to get tournaments") {
+ return
+ }
+
+ if tournaments == nil {
+ tournaments = []*domain.Tournament{}
+ }
+
+ c.JSON(http.StatusOK, tournaments)
+}
+
+// CreateTournament создает новый турнир
+// @Summary Create tournament (Admin)
+// @Description Create a new tournament. Available for any admin.
+// @Tags admin-tournaments
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Param tournament body domain.CreateTournament true "Tournament data"
+// @Success 201 {object} domain.Tournament
+// @Failure 400 {object} domain.ErrorResponse
+// @Failure 401 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /admin/tournaments [post]
+func (h *Handler) CreateTournament(c *gin.Context) {
+ var createData domain.CreateTournament
+ if err := c.ShouldBindJSON(&createData); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx := &usecase.Context{Context: c, User: nil}
+ tournament, err := h.tournamentCase.AdminCreate(ctx, &createData)
+ if ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to create tournament") {
+ return
+ }
+
+ c.JSON(http.StatusCreated, tournament)
+}
+
+// PatchTournament обновляет турнир
+// @Summary Update tournament (Admin)
+// @Description Update tournament data. Available for any admin.
+// @Tags admin-tournaments
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Param id path string true "Tournament ID"
+// @Param tournament body domain.AdminPatchTournament true "Tournament update data"
+// @Success 200 {object} domain.Tournament
+// @Failure 400 {object} domain.ErrorResponse
+// @Failure 401 {object} domain.ErrorResponse
+// @Failure 404 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /admin/tournaments/{id} [patch]
+func (h *Handler) PatchTournament(c *gin.Context) {
+ tournamentID := c.Param("id")
+ if tournamentID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Tournament ID is required"})
+ return
+ }
+
+ var patchData domain.AdminPatchTournament
+ if err := c.ShouldBindJSON(&patchData); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+
+ ctx := &usecase.Context{Context: c, User: nil}
+ tournament, err := h.tournamentCase.AdminPatch(ctx, tournamentID, &patchData)
+ if err != nil {
+ if err.Error() == fmt.Sprintf("tournament with id %s not found", tournamentID) {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to update tournament")
+ return
+ }
+
+ c.JSON(http.StatusOK, tournament)
+}
+
+// DeleteTournament удаляет турнир
+// @Summary Delete tournament (Admin)
+// @Description Delete tournament. Available for any admin.
+// @Tags admin-tournaments
+// @Produce json
+// @Security BearerAuth
+// @Param id path string true "Tournament ID"
+// @Success 200 {object} domain.MessageResponse
+// @Failure 400 {object} domain.ErrorResponse
+// @Failure 401 {object} domain.ErrorResponse
+// @Failure 404 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /admin/tournaments/{id} [delete]
+func (h *Handler) DeleteTournament(c *gin.Context) {
+ tournamentID := c.Param("id")
+ if tournamentID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Tournament ID is required"})
+ return
+ }
+
+ ctx := &usecase.Context{Context: c, User: nil}
+ err := h.tournamentCase.AdminDelete(ctx, tournamentID)
+ if err != nil {
+ if err.Error() == fmt.Sprintf("tournament with id %s not found", tournamentID) {
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ return
+ }
+ ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to delete tournament")
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"message": "Tournament deleted successfully"})
+}
\ No newline at end of file
diff --git a/server-go/pkg/gateways/rest/admin_tournaments/setup.go b/server-go/pkg/gateways/rest/admin_tournaments/setup.go
new file mode 100644
index 00000000..c60cd4a5
--- /dev/null
+++ b/server-go/pkg/gateways/rest/admin_tournaments/setup.go
@@ -0,0 +1,29 @@
+package admin_tournaments
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/middlewares"
+ "github.com/shampsdev/go-telegram-template/pkg/usecase"
+)
+
+func Setup(r *gin.RouterGroup, useCases usecase.Cases) {
+ handler := NewHandler(useCases.Tournament)
+
+ adminTournamentsGroup := r.Group("/admin/tournaments")
+ {
+ // Все эндпоинты требуют JWT авторизации
+ adminTournamentsGroup.Use(middlewares.RequireAdminJWT(useCases.AdminUser))
+
+ // POST /admin/tournaments/filter - получить турниры с фильтрацией (любой админ)
+ adminTournamentsGroup.POST("/filter", handler.GetAllTournaments)
+
+ // POST /admin/tournaments - создать новый турнир (любой админ)
+ adminTournamentsGroup.POST("", handler.CreateTournament)
+
+ // PATCH /admin/tournaments/:id - обновить турнир (любой админ)
+ adminTournamentsGroup.PATCH("/:id", handler.PatchTournament)
+
+ // DELETE /admin/tournaments/:id - удалить турнир (любой админ)
+ adminTournamentsGroup.DELETE("/:id", handler.DeleteTournament)
+ }
+}
\ No newline at end of file
diff --git a/server-go/pkg/gateways/rest/admin_waitlist/handlers.go b/server-go/pkg/gateways/rest/admin_waitlist/handlers.go
new file mode 100644
index 00000000..93d74d10
--- /dev/null
+++ b/server-go/pkg/gateways/rest/admin_waitlist/handlers.go
@@ -0,0 +1,52 @@
+package admin_waitlist
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/shampsdev/go-telegram-template/pkg/domain"
+ "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/ginerr"
+ "github.com/shampsdev/go-telegram-template/pkg/usecase"
+)
+
+type Handler struct {
+ waitlistCase *usecase.Waitlist
+}
+
+func NewHandler(waitlistCase *usecase.Waitlist) *Handler {
+ return &Handler{
+ waitlistCase: waitlistCase,
+ }
+}
+
+// GetTournamentWaitlist получает список ожидания для турнира
+// @Summary Get tournament waitlist (Admin)
+// @Description Get waitlist users for a specific tournament. Available for any admin. Read-only access.
+// @Tags admin-waitlist
+// @Accept json
+// @Produce json
+// @Security BearerAuth
+// @Param tournamentId path string true "Tournament ID"
+// @Success 200 {array} domain.WaitlistUser
+// @Failure 400 {object} domain.ErrorResponse
+// @Failure 401 {object} domain.ErrorResponse
+// @Failure 500 {object} domain.ErrorResponse
+// @Router /admin/waitlist/tournament/{tournamentId} [get]
+func (h *Handler) GetTournamentWaitlist(c *gin.Context) {
+ tournamentID := c.Param("tournamentId")
+ if tournamentID == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "Tournament ID is required"})
+ return
+ }
+
+ waitlistUsers, err := h.waitlistCase.GetTournamentWaitlistUsers(c.Request.Context(), tournamentID)
+ if ginerr.AbortIfErr(c, err, http.StatusInternalServerError, "Failed to get tournament waitlist") {
+ return
+ }
+
+ if waitlistUsers == nil {
+ waitlistUsers = []*domain.WaitlistUser{}
+ }
+
+ c.JSON(http.StatusOK, waitlistUsers)
+}
\ No newline at end of file
diff --git a/server-go/pkg/gateways/rest/admin_waitlist/setup.go b/server-go/pkg/gateways/rest/admin_waitlist/setup.go
new file mode 100644
index 00000000..265fc5da
--- /dev/null
+++ b/server-go/pkg/gateways/rest/admin_waitlist/setup.go
@@ -0,0 +1,20 @@
+package admin_waitlist
+
+import (
+ "github.com/gin-gonic/gin"
+ "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/middlewares"
+ "github.com/shampsdev/go-telegram-template/pkg/usecase"
+)
+
+func Setup(r *gin.RouterGroup, useCases usecase.Cases) {
+ handler := NewHandler(useCases.Waitlist)
+
+ adminWaitlistGroup := r.Group("/admin/waitlist")
+ {
+ // Все эндпоинты требуют JWT авторизации
+ adminWaitlistGroup.Use(middlewares.RequireAdminJWT(useCases.AdminUser))
+
+ // GET /admin/waitlist/tournament/:tournamentId - получить список ожидания турнира (любой админ, только чтение)
+ adminWaitlistGroup.GET("/tournament/:tournamentId", handler.GetTournamentWaitlist)
+ }
+}
\ No newline at end of file
diff --git a/server-go/pkg/gateways/rest/router.go b/server-go/pkg/gateways/rest/router.go
index 128d126c..ed3a4e38 100644
--- a/server-go/pkg/gateways/rest/router.go
+++ b/server-go/pkg/gateways/rest/router.go
@@ -8,10 +8,13 @@ import (
"github.com/shampsdev/go-telegram-template/pkg/config"
"github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_admins"
"github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_auth"
+ "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_clubs"
"github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_courts"
"github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_loyalties"
"github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_registrations"
+ "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_tournaments"
"github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_users"
+ "github.com/shampsdev/go-telegram-template/pkg/gateways/rest/admin_waitlist"
"github.com/shampsdev/go-telegram-template/pkg/gateways/rest/club"
"github.com/shampsdev/go-telegram-template/pkg/gateways/rest/courts"
"github.com/shampsdev/go-telegram-template/pkg/gateways/rest/image"
@@ -44,9 +47,12 @@ func setupRouter(ctx context.Context, r *gin.Engine, useCases usecase.Cases, cfg
registration.Setup(v1, useCases)
webhook.Setup(v1, useCases, cfg)
admin_auth.Setup(v1, useCases)
+ admin_clubs.Setup(v1, useCases)
admin_users.Setup(v1, useCases)
admin_admins.Setup(v1, useCases)
admin_loyalties.Setup(v1, useCases)
admin_courts.Setup(v1, useCases)
admin_registrations.Setup(v1, useCases)
+ admin_tournaments.Setup(v1, useCases)
+ admin_waitlist.Setup(v1, useCases)
}
diff --git a/server-go/pkg/repo/pg/club.go b/server-go/pkg/repo/pg/club.go
index 1d39a106..7da71fe5 100644
--- a/server-go/pkg/repo/pg/club.go
+++ b/server-go/pkg/repo/pg/club.go
@@ -150,4 +150,55 @@ func (r *ClubRepo) GetUserClubs(ctx context.Context, userID string) ([]*domain.C
}
return clubs, nil
+}
+
+// Patch обновляет клуб
+// Примечание: ID клуба нельзя изменить после создания
+func (r *ClubRepo) Patch(ctx context.Context, clubID string, patch *domain.PatchClub) error {
+ s := r.psql.Update(`"clubs"`).Where(sq.Eq{"id": clubID})
+
+ if patch.Name != nil {
+ s = s.Set("name", *patch.Name)
+ }
+
+ if patch.Description != nil {
+ s = s.Set("description", *patch.Description)
+ }
+
+ sql, args, err := s.ToSql()
+ if err != nil {
+ return fmt.Errorf("failed to build SQL: %w", err)
+ }
+
+ result, err := r.db.Exec(ctx, sql, args...)
+ if err != nil {
+ return fmt.Errorf("failed to update club: %w", err)
+ }
+
+ if result.RowsAffected() == 0 {
+ return fmt.Errorf("club with id %s not found", clubID)
+ }
+
+ return nil
+}
+
+// Delete удаляет клуб
+func (r *ClubRepo) Delete(ctx context.Context, clubID string) error {
+ s := r.psql.Delete(`"clubs"`).Where(sq.Eq{"id": clubID})
+
+ sql, args, err := s.ToSql()
+ if err != nil {
+ return fmt.Errorf("failed to build SQL: %w", err)
+ }
+
+ result, err := r.db.Exec(ctx, sql, args...)
+ if err != nil {
+ return fmt.Errorf("failed to delete club: %w", err)
+ }
+
+ if result.RowsAffected() == 0 {
+ return fmt.Errorf("club with id %s not found", clubID)
+ }
+
+ return nil
}
\ No newline at end of file
diff --git a/server-go/pkg/repo/pg/tournament.go b/server-go/pkg/repo/pg/tournament.go
index df090477..0653d9d9 100644
--- a/server-go/pkg/repo/pg/tournament.go
+++ b/server-go/pkg/repo/pg/tournament.go
@@ -72,8 +72,6 @@ func (r *TournamentRepo) Filter(ctx context.Context, filter *domain.FilterTourna
return nil, fmt.Errorf("failed to build SQL: %w", err)
}
-
-
rows, err := r.db.Query(ctx, sql, args...)
if errors.Is(err, pgx.ErrNoRows) {
return []*domain.Tournament{}, nil
@@ -406,3 +404,256 @@ func (r *TournamentRepo) Delete(ctx context.Context, id string) error {
_, err = r.db.Exec(ctx, sql, args...)
return err
}
+
+// AdminFilter получает турниры для админов с расширенной фильтрацией
+func (r *TournamentRepo) AdminFilter(ctx context.Context, filter *domain.AdminFilterTournament) ([]*domain.Tournament, error) {
+ s := r.psql.Select(
+ `"t"."id"`, `"t"."name"`, `"t"."start_time"`, `"t"."end_time"`, `"t"."price"`,
+ `"t"."rank_min"`, `"t"."rank_max"`, `"t"."max_users"`, `"t"."description"`, `"t"."club_id"`, `"t"."tournament_type"`,
+ `"c"."id"`, `"c"."name"`, `"c"."address"`,
+ `"u"."id"`, `"u"."telegram_id"`, `"u"."telegram_username"`, `"u"."first_name"`, `"u"."last_name"`, `"u"."avatar"`,
+ `"u"."bio"`, `"u"."rank"`, `"u"."city"`, `"u"."birth_date"`, `"u"."playing_position"`, `"u"."padel_profiles"`, `"u"."is_registered"`,
+ `COUNT(CASE WHEN "r"."status" IN ('PENDING', 'ACTIVE') THEN 1 END) AS active_registrations`,
+ ).
+ From(`"tournaments" AS t`).
+ Join(`"courts" AS c ON "t"."court_id" = "c"."id"`).
+ Join(`"users" AS u ON "t"."organizator_id" = "u"."id"`).
+ LeftJoin(`"registrations" AS r ON "t"."id" = "r"."tournament_id"`).
+ GroupBy(`"t"."id"`, `"c"."id"`, `"u"."id"`)
+
+ if filter.ID != nil {
+ s = s.Where(sq.Eq{`"t"."id"`: *filter.ID})
+ }
+
+ if filter.Name != nil {
+ s = s.Where(sq.ILike{`"t"."name"`: "%" + *filter.Name + "%"})
+ }
+
+ if filter.ClubID != nil {
+ s = s.Where(sq.Eq{`"t"."club_id"`: *filter.ClubID})
+ }
+
+ if filter.OrganizatorID != nil {
+ s = s.Where(sq.Eq{`"t"."organizator_id"`: *filter.OrganizatorID})
+ }
+
+ if filter.TournamentType != nil {
+ s = s.Where(sq.Eq{`"t"."tournament_type"`: *filter.TournamentType})
+ }
+
+ if filter.OrganizatorTelegramID != nil {
+ s = s.Where(sq.Eq{`"u"."telegram_id"`: *filter.OrganizatorTelegramID})
+ }
+
+ if filter.OrganizatorFirstName != nil {
+ s = s.Where(sq.ILike{`"u"."first_name"`: "%" + *filter.OrganizatorFirstName + "%"})
+ }
+
+ if filter.ClubName != nil {
+ s = s.Join(`"clubs" AS cl ON "t"."club_id" = "cl"."id"`).
+ Where(sq.ILike{`"cl"."name"`: "%" + *filter.ClubName + "%"})
+ }
+
+ s = s.OrderBy(`"t"."start_time" DESC`)
+
+ sql, args, err := s.ToSql()
+ if err != nil {
+ return nil, fmt.Errorf("failed to build SQL: %w", err)
+ }
+
+ rows, err := r.db.Query(ctx, sql, args...)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return []*domain.Tournament{}, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to execute SQL: %w", err)
+ }
+ defer rows.Close()
+
+ tournaments := []*domain.Tournament{}
+ for rows.Next() {
+ var tournament domain.Tournament
+ var endTime pgtype.Timestamp
+ var description pgtype.Text
+ var courtID, courtName, courtAddress string
+ var userID string
+ var userTelegramID int64
+ var userTelegramUsername pgtype.Text
+ var userFirstName, userSecondName string
+ var userAvatar pgtype.Text
+ var userBio pgtype.Text
+ var userRank float64
+ var userCity pgtype.Text
+ var userBirthDate pgtype.Date
+ var userPlayingPosition pgtype.Text
+ var userPadelProfiles pgtype.Text
+ var userIsRegistered pgtype.Bool
+ var activeRegistrations int
+
+ err := rows.Scan(
+ &tournament.ID,
+ &tournament.Name,
+ &tournament.StartTime,
+ &endTime,
+ &tournament.Price,
+ &tournament.RankMin,
+ &tournament.RankMax,
+ &tournament.MaxUsers,
+ &description,
+ &tournament.ClubID,
+ &tournament.TournamentType,
+ &courtID,
+ &courtName,
+ &courtAddress,
+ &userID,
+ &userTelegramID,
+ &userTelegramUsername,
+ &userFirstName,
+ &userSecondName,
+ &userAvatar,
+ &userBio,
+ &userRank,
+ &userCity,
+ &userBirthDate,
+ &userPlayingPosition,
+ &userPadelProfiles,
+ &userIsRegistered,
+ &activeRegistrations,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan row: %w", err)
+ }
+
+ if endTime.Valid {
+ tournament.EndTime = endTime.Time
+ }
+ if description.Valid {
+ tournament.Description = description.String
+ }
+
+ tournament.Court = domain.Court{
+ ID: courtID,
+ Name: courtName,
+ Address: courtAddress,
+ }
+
+ organizator := domain.User{
+ ID: userID,
+ UserTGData: domain.UserTGData{
+ TelegramID: userTelegramID,
+ FirstName: userFirstName,
+ LastName: userSecondName,
+ },
+ Rank: userRank,
+ }
+
+ if userTelegramUsername.Valid {
+ organizator.TelegramUsername = userTelegramUsername.String
+ }
+ if userAvatar.Valid {
+ organizator.Avatar = userAvatar.String
+ }
+ if userBio.Valid {
+ organizator.Bio = userBio.String
+ }
+ if userCity.Valid {
+ organizator.City = userCity.String
+ }
+ if userBirthDate.Valid {
+ organizator.BirthDate = userBirthDate.Time.Format("2006-01-02")
+ }
+ if userPlayingPosition.Valid {
+ organizator.PlayingPosition = domain.PlayingPosition(userPlayingPosition.String)
+ }
+ if userPadelProfiles.Valid {
+ organizator.PadelProfiles = userPadelProfiles.String
+ }
+ if userIsRegistered.Valid {
+ organizator.IsRegistered = userIsRegistered.Bool
+ }
+
+ tournament.Organizator = organizator
+ tournaments = append(tournaments, &tournament)
+ }
+
+ return tournaments, nil
+}
+
+// AdminPatch обновляет турнир для админов с расширенными правами
+func (r *TournamentRepo) AdminPatch(ctx context.Context, id string, tournament *domain.AdminPatchTournament) error {
+ s := r.psql.Update(`"tournaments"`).Where(sq.Eq{"id": id})
+
+ if tournament.Name != nil {
+ s = s.Set("name", *tournament.Name)
+ }
+ if tournament.StartTime != nil {
+ s = s.Set("start_time", *tournament.StartTime)
+ }
+ if tournament.EndTime != nil {
+ s = s.Set("end_time", *tournament.EndTime)
+ }
+ if tournament.Price != nil {
+ s = s.Set("price", *tournament.Price)
+ }
+ if tournament.RankMin != nil {
+ s = s.Set("rank_min", *tournament.RankMin)
+ }
+ if tournament.RankMax != nil {
+ s = s.Set("rank_max", *tournament.RankMax)
+ }
+ if tournament.MaxUsers != nil {
+ s = s.Set("max_users", *tournament.MaxUsers)
+ }
+ if tournament.Description != nil {
+ s = s.Set("description", *tournament.Description)
+ }
+ if tournament.CourtID != nil {
+ s = s.Set("court_id", *tournament.CourtID)
+ }
+ if tournament.ClubID != nil {
+ s = s.Set("club_id", *tournament.ClubID)
+ }
+ if tournament.TournamentType != nil {
+ s = s.Set("tournament_type", *tournament.TournamentType)
+ }
+ if tournament.OrganizatorID != nil {
+ s = s.Set("organizator_id", *tournament.OrganizatorID)
+ }
+
+ sql, args, err := s.ToSql()
+ if err != nil {
+ return fmt.Errorf("failed to build SQL: %w", err)
+ }
+
+ result, err := r.db.Exec(ctx, sql, args...)
+ if err != nil {
+ return fmt.Errorf("failed to update tournament: %w", err)
+ }
+
+ if result.RowsAffected() == 0 {
+ return fmt.Errorf("tournament with id %s not found", id)
+ }
+
+ return nil
+}
+
+// AdminDelete удаляет турнир для админов
+func (r *TournamentRepo) AdminDelete(ctx context.Context, id string) error {
+ s := r.psql.Delete(`"tournaments"`).Where(sq.Eq{"id": id})
+
+ sql, args, err := s.ToSql()
+ if err != nil {
+ return fmt.Errorf("failed to build SQL: %w", err)
+ }
+
+ result, err := r.db.Exec(ctx, sql, args...)
+ if err != nil {
+ return fmt.Errorf("failed to delete tournament: %w", err)
+ }
+
+ if result.RowsAffected() == 0 {
+ return fmt.Errorf("tournament with id %s not found", id)
+ }
+
+ return nil
+}
diff --git a/server-go/pkg/repo/repo.go b/server-go/pkg/repo/repo.go
index ae1509e8..4ec11c13 100644
--- a/server-go/pkg/repo/repo.go
+++ b/server-go/pkg/repo/repo.go
@@ -40,6 +40,8 @@ type Club interface {
Filter(ctx context.Context, filter *domain.FilterClub) ([]*domain.Club, error)
JoinClub(ctx context.Context, clubID, userID string) error
GetUserClubs(ctx context.Context, userID string) ([]*domain.Club, error)
+ Patch(ctx context.Context, clubID string, patch *domain.PatchClub) error
+ Delete(ctx context.Context, clubID string) error
}
type Loyalty interface {
@@ -55,6 +57,9 @@ type Tournament interface {
Filter(ctx context.Context, filter *domain.FilterTournament) ([]*domain.Tournament, error)
GetTournamentsByUserID(ctx context.Context, userID string) ([]*domain.Tournament, error)
Delete(ctx context.Context, id string) error
+ AdminFilter(ctx context.Context, filter *domain.AdminFilterTournament) ([]*domain.Tournament, error)
+ AdminPatch(ctx context.Context, id string, tournament *domain.AdminPatchTournament) error
+ AdminDelete(ctx context.Context, id string) error
}
type Registration interface {
diff --git a/server-go/pkg/usecase/club.go b/server-go/pkg/usecase/club.go
index 9c4a01a2..6b79cddd 100644
--- a/server-go/pkg/usecase/club.go
+++ b/server-go/pkg/usecase/club.go
@@ -80,4 +80,24 @@ func (uc *Club) GetUserClubs(ctx *Context) ([]*domain.Club, error) {
}
return uc.repo.GetUserClubs(ctx, ctx.User.ID)
+}
+
+// Patch обновляет клуб (админская операция)
+func (uc *Club) Patch(ctx *Context, clubID string, patch *domain.PatchClub) error {
+ return uc.repo.Patch(ctx.Context, clubID, patch)
+}
+
+// Delete удаляет клуб (админская операция)
+func (uc *Club) Delete(ctx *Context, clubID string) error {
+ return uc.repo.Delete(ctx.Context, clubID)
+}
+
+// AdminFilter получает клубы для админов (без проверки аутентификации)
+func (uc *Club) AdminFilter(ctx *Context, filter *domain.FilterClub) ([]*domain.Club, error) {
+ return uc.repo.Filter(ctx.Context, filter)
+}
+
+// AdminCreate создает клуб для админов (без проверки аутентификации)
+func (uc *Club) AdminCreate(ctx *Context, club *domain.CreateClub) error {
+ return uc.repo.Create(ctx.Context, club)
}
\ No newline at end of file
diff --git a/server-go/pkg/usecase/tournament.go b/server-go/pkg/usecase/tournament.go
index d86ea00e..f06abd68 100644
--- a/server-go/pkg/usecase/tournament.go
+++ b/server-go/pkg/usecase/tournament.go
@@ -159,3 +159,69 @@ func (t *Tournament) GetTournamentParticipants(ctx context.Context, tournamentID
return participants, nil
}
+
+// AdminFilter получает турниры для админов с расширенной фильтрацией
+func (t *Tournament) AdminFilter(ctx *Context, filter *domain.AdminFilterTournament) ([]*domain.Tournament, error) {
+ tournaments, err := t.TournamentRepo.AdminFilter(ctx.Context, filter)
+ if err != nil {
+ return nil, err
+ }
+
+ // Добавляем участников для каждого турнира
+ for _, tournament := range tournaments {
+ participants, err := t.GetTournamentParticipants(ctx.Context, tournament.ID)
+ if err != nil {
+ return nil, err
+ }
+ tournament.Participants = participants
+ }
+
+ return tournaments, nil
+}
+
+// AdminCreate создает турнир для админов
+func (t *Tournament) AdminCreate(ctx *Context, tournament *domain.CreateTournament) (*domain.Tournament, error) {
+ id, err := t.TournamentRepo.Create(ctx.Context, tournament)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create tournament: %w", err)
+ }
+
+ // Получаем созданный турнир
+ filter := &domain.AdminFilterTournament{ID: &id}
+ tournaments, err := t.AdminFilter(ctx, filter)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get created tournament: %w", err)
+ }
+
+ if len(tournaments) == 0 {
+ return nil, fmt.Errorf("created tournament not found")
+ }
+
+ return tournaments[0], nil
+}
+
+// AdminPatch обновляет турнир для админов
+func (t *Tournament) AdminPatch(ctx *Context, id string, tournament *domain.AdminPatchTournament) (*domain.Tournament, error) {
+ err := t.TournamentRepo.AdminPatch(ctx.Context, id, tournament)
+ if err != nil {
+ return nil, fmt.Errorf("failed to patch tournament: %w", err)
+ }
+
+ // Получаем обновленный турнир
+ filter := &domain.AdminFilterTournament{ID: &id}
+ tournaments, err := t.AdminFilter(ctx, filter)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get updated tournament: %w", err)
+ }
+
+ if len(tournaments) == 0 {
+ return nil, fmt.Errorf("updated tournament not found")
+ }
+
+ return tournaments[0], nil
+}
+
+// AdminDelete удаляет турнир для админов
+func (t *Tournament) AdminDelete(ctx *Context, id string) error {
+ return t.TournamentRepo.AdminDelete(ctx.Context, id)
+}
diff --git a/server-go/server.exe b/server-go/server.exe
new file mode 100644
index 00000000..a071d119
Binary files /dev/null and b/server-go/server.exe differ
|