diff --git a/backend/apps/users/migrations/0013_invitation_link_model.py b/backend/apps/users/migrations/0013_invitation_link_model.py
new file mode 100644
index 0000000..53ac7fb
--- /dev/null
+++ b/backend/apps/users/migrations/0013_invitation_link_model.py
@@ -0,0 +1,68 @@
+# Generated by Django 4.2.7 on 2026-03-12 14:06
+
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0012_add_group_to_board"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="InvitationLink",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "token",
+ models.CharField(
+ db_index=True, max_length=64, unique=True, verbose_name="Токен"
+ ),
+ ),
+ (
+ "created_at",
+ models.DateTimeField(auto_now_add=True, verbose_name="Создана"),
+ ),
+ (
+ "is_banned",
+ models.BooleanField(default=False, verbose_name="Забанена"),
+ ),
+ (
+ "mentor",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="invitation_links",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Ментор",
+ ),
+ ),
+ (
+ "used_by",
+ models.OneToOneField(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="registered_via_link",
+ to=settings.AUTH_USER_MODEL,
+ verbose_name="Использована пользователем",
+ ),
+ ),
+ ],
+ options={
+ "verbose_name": "Ссылка-приглашение",
+ "verbose_name_plural": "Ссылки-приглашения",
+ "db_table": "invitation_links",
+ "ordering": ["-created_at"],
+ },
+ ),
+ ]
diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py
index 27d9132..0d95754 100644
--- a/backend/apps/users/models.py
+++ b/backend/apps/users/models.py
@@ -691,4 +691,50 @@ class Group(models.Model):
]
def __str__(self):
- return f"{self.name} (ментор: {self.mentor.get_full_name()})"
\ No newline at end of file
+ return f"{self.name} (ментор: {self.mentor.get_full_name()})"
+
+
+class InvitationLink(models.Model):
+ """
+ Ссылка-приглашение от ментора для регистрации ученика.
+ - Каждая ссылка действует 12 часов с момента создания
+ - Одна ссылка — один ученик (used_by)
+ - Несколько ссылок могут быть активны одновременно
+ - По истечении 12 часов ссылка помечается is_banned=True и не может быть использована
+ """
+ token = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='Токен')
+ mentor = models.ForeignKey(
+ 'User',
+ on_delete=models.CASCADE,
+ related_name='invitation_links',
+ verbose_name='Ментор',
+ )
+ created_at = models.DateTimeField(auto_now_add=True, verbose_name='Создана')
+ used_by = models.OneToOneField(
+ 'User',
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name='registered_via_link',
+ verbose_name='Использована пользователем',
+ )
+ is_banned = models.BooleanField(default=False, verbose_name='Забанена')
+
+ class Meta:
+ db_table = 'invitation_links'
+ verbose_name = 'Ссылка-приглашение'
+ verbose_name_plural = 'Ссылки-приглашения'
+ ordering = ['-created_at']
+
+ def __str__(self):
+ return f"InvitationLink({self.mentor.email}, {self.token[:8]}...)"
+
+ @property
+ def is_expired(self):
+ from django.utils import timezone as tz
+ from datetime import timedelta
+ return tz.now() > self.created_at + timedelta(hours=12)
+
+ @property
+ def is_valid(self):
+ return not self.is_banned and not self.is_expired and self.used_by_id is None
diff --git a/backend/apps/users/profile_views.py b/backend/apps/users/profile_views.py
index 4fbe82c..ee6fe5d 100644
--- a/backend/apps/users/profile_views.py
+++ b/backend/apps/users/profile_views.py
@@ -1253,23 +1253,38 @@ class ClientManagementViewSet(viewsets.ViewSet):
@action(detail=False, methods=['post'], url_path='generate-invitation-link')
def generate_invitation_link(self, request):
"""
- Сгенерировать или обновить токен ссылки-приглашения.
- POST /api/users/manage/clients/generate-invitation-link/
+ Создать новую ссылку-приглашение (12 часов, 1 использование).
+ Старые ссылки остаются действительными.
+ POST /api/manage/clients/generate-invitation-link/
"""
+ from django.utils import timezone as tz
+ from datetime import timedelta
+ from .models import InvitationLink
+
user = request.user
if user.role != 'mentor':
return Response({'error': 'Только для менторов'}, status=status.HTTP_403_FORBIDDEN)
-
- user.invitation_link_token = secrets.token_urlsafe(32)
- user.save(update_fields=['invitation_link_token'])
-
+
+ # Истекаем просроченные ссылки этого ментора
+ expire_before = tz.now() - timedelta(hours=12)
+ InvitationLink.objects.filter(
+ mentor=user,
+ is_banned=False,
+ used_by__isnull=True,
+ created_at__lt=expire_before,
+ ).update(is_banned=True)
+
+ token = secrets.token_urlsafe(32)
+ inv = InvitationLink.objects.create(mentor=user, token=token)
+
from django.conf import settings
frontend_url = getattr(settings, 'FRONTEND_URL', '').rstrip('/')
- link = f"{frontend_url}/invite/{user.invitation_link_token}"
-
+ link = f"{frontend_url}/invite/{token}"
+
return Response({
- 'invitation_link_token': user.invitation_link_token,
- 'invitation_link': link
+ 'invitation_link_token': token,
+ 'invitation_link': link,
+ 'expires_at': (inv.created_at + timedelta(hours=12)).isoformat(),
})
@@ -1453,20 +1468,37 @@ class InvitationViewSet(viewsets.ViewSet):
Получить информацию о менторе по токену ссылки-приглашения.
GET /api/invitation/info-by-token/?token=...
"""
+ from django.utils import timezone as tz
+ from datetime import timedelta
+ from .models import InvitationLink
+
token = request.query_params.get('token')
if not token:
return Response({'error': 'Токен не указан'}, status=status.HTTP_400_BAD_REQUEST)
-
+
try:
- mentor = User.objects.get(invitation_link_token=token, role='mentor')
- return Response({
- 'mentor_name': mentor.get_full_name(),
- 'mentor_id': mentor.id,
- 'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None,
- })
- except User.DoesNotExist:
+ inv = InvitationLink.objects.select_related('mentor').get(token=token)
+ except InvitationLink.DoesNotExist:
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
+ if inv.is_banned:
+ return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
+ if inv.is_expired:
+ inv.is_banned = True
+ inv.save(update_fields=['is_banned'])
+ return Response({'error': 'Ссылка истекла'}, status=status.HTTP_410_GONE)
+ if inv.used_by_id is not None:
+ return Response({'error': 'Ссылка уже использована'}, status=status.HTTP_410_GONE)
+
+ mentor = inv.mentor
+ expires_at = inv.created_at + timedelta(hours=12)
+ return Response({
+ 'mentor_name': mentor.get_full_name(),
+ 'mentor_id': mentor.id,
+ 'avatar_url': request.build_absolute_uri(mentor.avatar.url) if mentor.avatar else None,
+ 'expires_at': expires_at.isoformat(),
+ })
+
@action(detail=False, methods=['post'], url_path='register-by-link', permission_classes=[AllowAny])
def register_by_link(self, request):
"""
@@ -1482,6 +1514,8 @@ class InvitationViewSet(viewsets.ViewSet):
"city": "..."
}
"""
+ from .models import InvitationLink
+
token = request.data.get('token')
first_name = request.data.get('first_name')
last_name = request.data.get('last_name')
@@ -1494,10 +1528,21 @@ class InvitationViewSet(viewsets.ViewSet):
return Response({'error': 'Имя, фамилия и токен обязательны'}, status=status.HTTP_400_BAD_REQUEST)
try:
- mentor = User.objects.get(invitation_link_token=token, role='mentor')
- except User.DoesNotExist:
+ inv = InvitationLink.objects.select_related('mentor').get(token=token)
+ except InvitationLink.DoesNotExist:
return Response({'error': 'Недействительная ссылка'}, status=status.HTTP_404_NOT_FOUND)
+ if inv.is_banned:
+ return Response({'error': 'Ссылка заблокирована'}, status=status.HTTP_410_GONE)
+ if inv.is_expired:
+ inv.is_banned = True
+ inv.save(update_fields=['is_banned'])
+ return Response({'error': 'Срок действия ссылки истёк'}, status=status.HTTP_410_GONE)
+ if inv.used_by_id is not None:
+ return Response({'error': 'Ссылка уже была использована'}, status=status.HTTP_410_GONE)
+
+ mentor = inv.mentor
+
# Если email указан, проверяем его уникальность
if email:
if User.objects.filter(email=email).exists():
@@ -1522,7 +1567,11 @@ class InvitationViewSet(viewsets.ViewSet):
# Генерируем персональный токен для входа
student_user.login_token = secrets.token_urlsafe(32)
student_user.save(update_fields=['login_token'])
-
+
+ # Помечаем ссылку как использованную
+ inv.used_by = student_user
+ inv.save(update_fields=['used_by'])
+
# Создаем профиль клиента
client = Client.objects.create(user=student_user)
@@ -1601,65 +1650,80 @@ class ParentManagementViewSet(viewsets.ViewSet):
status=status.HTTP_403_FORBIDDEN
)
- # Поддержка старого формата (child_email) и нового (email + данные)
+ # Поддержка: universal_code (8 симв.) / child_email / email
+ universal_code = (request.data.get('universal_code') or '').strip().upper()
child_email = request.data.get('child_email') or request.data.get('email')
-
- if not child_email:
+
+ if not universal_code and not child_email:
return Response(
- {'error': 'Необходимо указать email ребенка'},
+ {'error': 'Необходимо указать 8-значный код ребенка или его email'},
status=status.HTTP_400_BAD_REQUEST
)
-
- # Нормализуем email
- child_email = child_email.lower().strip()
-
+
# Получаем или создаем профиль родителя
- parent, created = Parent.objects.get_or_create(user=user)
-
- # Ищем пользователя
+ parent, _ = Parent.objects.get_or_create(user=user)
+
created = False
- try:
- child_user = User.objects.get(email=child_email)
- # Если пользователь существует, проверяем что это клиент
+
+ if universal_code:
+ # --- Поиск по universal_code ---
+ allowed = set('ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789')
+ valid_8 = len(universal_code) == 8 and all(c in allowed for c in universal_code)
+ valid_6_legacy = len(universal_code) == 6 and universal_code.isdigit()
+ if not (valid_8 or valid_6_legacy):
+ return Response(
+ {'error': 'Код должен содержать 8 символов (буквы и цифры)'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ try:
+ child_user = User.objects.get(universal_code=universal_code)
+ except User.DoesNotExist:
+ return Response(
+ {'error': 'Пользователь с таким кодом не найден'},
+ status=status.HTTP_404_NOT_FOUND
+ )
if child_user.role != 'client':
return Response(
- {'error': 'Пользователь с таким email уже существует, но не является клиентом'},
+ {'error': 'Пользователь с этим кодом не является учеником (client)'},
status=status.HTTP_400_BAD_REQUEST
)
- except User.DoesNotExist:
- created = True
- # Создаем нового пользователя-клиента
- first_name = request.data.get('first_name', '').strip()
- last_name = request.data.get('last_name', '').strip()
- phone = request.data.get('phone', '').strip()
-
- if not first_name or not last_name:
- return Response(
- {'error': 'Для создания нового пользователя необходимо указать имя и фамилию'},
- status=status.HTTP_400_BAD_REQUEST
+ else:
+ # --- Поиск / создание по email ---
+ child_email = child_email.lower().strip()
+ try:
+ child_user = User.objects.get(email=child_email)
+ if child_user.role != 'client':
+ return Response(
+ {'error': 'Пользователь с таким email не является учеником (client)'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+ except User.DoesNotExist:
+ created = True
+ first_name = request.data.get('first_name', '').strip()
+ last_name = request.data.get('last_name', '').strip()
+ phone = request.data.get('phone', '').strip()
+
+ if not first_name or not last_name:
+ return Response(
+ {'error': 'Для создания нового пользователя необходимо указать имя и фамилию'},
+ status=status.HTTP_400_BAD_REQUEST
+ )
+
+ temp_password = secrets.token_urlsafe(12)
+ child_user = User.objects.create_user(
+ email=child_email,
+ password=temp_password,
+ first_name=first_name,
+ last_name=last_name,
+ phone=normalize_phone(phone) if phone else '',
+ role='client',
+ email_verified=True,
)
-
- # Создаем пользователя с временным паролем
- temp_password = secrets.token_urlsafe(12)
-
- child_user = User.objects.create_user(
- email=child_email,
- password=temp_password,
- first_name=first_name,
- last_name=last_name,
- phone=normalize_phone(phone) if phone else '',
- role='client',
- email_verified=True, # Email автоматически подтвержден при добавлении родителем
- )
-
- # Генерируем токен для установки пароля
- reset_token = secrets.token_urlsafe(32)
- child_user.email_verification_token = reset_token
- child_user.save()
-
- # Отправляем приветственное письмо со ссылкой на установку пароля
- send_student_welcome_email_task.delay(child_user.id, reset_token)
-
+ reset_token = secrets.token_urlsafe(32)
+ child_user.email_verification_token = reset_token
+ child_user.save()
+ send_student_welcome_email_task.delay(child_user.id, reset_token)
+
# Получаем или создаем профиль клиента
if created: # Если пользователь был только что создан
child = Client.objects.create(
diff --git a/front_minimal/src/app/invite/[token]/page.jsx b/front_minimal/src/app/invite/[token]/page.jsx
new file mode 100644
index 0000000..d477599
--- /dev/null
+++ b/front_minimal/src/app/invite/[token]/page.jsx
@@ -0,0 +1,10 @@
+import { CONFIG } from 'src/config-global';
+import { InviteRegisterView } from 'src/sections/invite/view';
+
+// ----------------------------------------------------------------------
+
+export const metadata = { title: `Регистрация по приглашению | ${CONFIG.site.name}` };
+
+export default function Page({ params }) {
+ return ;
+}
diff --git a/front_minimal/src/routes/paths.js b/front_minimal/src/routes/paths.js
index d4ca7cd..01b3c19 100644
--- a/front_minimal/src/routes/paths.js
+++ b/front_minimal/src/routes/paths.js
@@ -7,6 +7,7 @@ const ROOTS = {
export const paths = {
videoCall: '/video-call',
+ invite: (token) => `/invite/${token}`,
page404: '/404',
// Auth
diff --git a/front_minimal/src/routes/sections.jsx b/front_minimal/src/routes/sections.jsx
index 6f77cc2..a0c1352 100644
--- a/front_minimal/src/routes/sections.jsx
+++ b/front_minimal/src/routes/sections.jsx
@@ -108,6 +108,10 @@ const VideoCallView = lazy(() =>
import('src/sections/video-call/view').then((m) => ({ default: m.VideoCallView }))
);
+const InviteRegisterView = lazy(() =>
+ import('src/sections/invite/view').then((m) => ({ default: m.InviteRegisterView }))
+);
+
const PrejoinView = lazy(() =>
import('src/sections/prejoin/view/prejoin-view').then((m) => ({ default: m.PrejoinView }))
);
@@ -145,6 +149,11 @@ function AuthLayoutWrapper() {
);
}
+function InviteWrapper() {
+ const { token } = useParams();
+ return ;
+}
+
function LessonDetailWrapper() {
const { id } = useParams();
return ;
@@ -185,6 +194,9 @@ export function Router() {
// Video call — fullscreen, no sidebar
{ path: 'video-call', element: },
+ // Invite link — public, no auth required
+ { path: 'invite/:token', element: },
+
// Dashboard
{
path: 'dashboard',
diff --git a/front_minimal/src/sections/account-platform/view/account-platform-view.jsx b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx
index badd797..1eeab6e 100644
--- a/front_minimal/src/sections/account-platform/view/account-platform-view.jsx
+++ b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx
@@ -858,6 +858,31 @@ export function AccountPlatformView() {
disabled
helperText="Изменить email нельзя"
/>
+ {user?.universal_code && (
+
+ {
+ navigator.clipboard.writeText(user.universal_code);
+ showSnack('Код скопирован');
+ }}
+ >
+
+
+
+ ),
+ }}
+ />
+ )}
diff --git a/front_minimal/src/sections/invite/view/index.js b/front_minimal/src/sections/invite/view/index.js
new file mode 100644
index 0000000..8a22ac3
--- /dev/null
+++ b/front_minimal/src/sections/invite/view/index.js
@@ -0,0 +1 @@
+export { InviteRegisterView } from './invite-register-view';
diff --git a/front_minimal/src/sections/invite/view/invite-register-view.jsx b/front_minimal/src/sections/invite/view/invite-register-view.jsx
new file mode 100644
index 0000000..a5a2ca2
--- /dev/null
+++ b/front_minimal/src/sections/invite/view/invite-register-view.jsx
@@ -0,0 +1,316 @@
+'use client';
+
+import { z as zod } from 'zod';
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { useForm, Controller } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import Box from '@mui/material/Box';
+import Card from '@mui/material/Card';
+import Alert from '@mui/material/Alert';
+import Stack from '@mui/material/Stack';
+import Avatar from '@mui/material/Avatar';
+import IconButton from '@mui/material/IconButton';
+import Typography from '@mui/material/Typography';
+import CardContent from '@mui/material/CardContent';
+import LoadingButton from '@mui/lab/LoadingButton';
+import InputAdornment from '@mui/material/InputAdornment';
+import CircularProgress from '@mui/material/CircularProgress';
+import Autocomplete from '@mui/material/Autocomplete';
+import TextField from '@mui/material/TextField';
+
+import { useRouter } from 'src/routes/hooks';
+import { paths } from 'src/routes/paths';
+
+import { useBoolean } from 'src/hooks/use-boolean';
+import { useAuthContext } from 'src/auth/hooks';
+import { setSession } from 'src/auth/context/jwt/utils';
+
+import { Logo } from 'src/components/logo';
+import { Iconify } from 'src/components/iconify';
+import { Form, Field } from 'src/components/hook-form';
+
+import { searchCities } from 'src/utils/profile-api';
+import { getInviteLinkInfo, registerByInviteLink } from 'src/utils/students-api';
+
+// ----------------------------------------------------------------------
+
+const RegisterSchema = zod
+ .object({
+ firstName: zod.string().min(1, { message: 'Введите имя!' }),
+ lastName: zod.string().min(1, { message: 'Введите фамилию!' }),
+ email: zod
+ .string()
+ .min(1, { message: 'Введите email!' })
+ .email({ message: 'Введите корректный email!' }),
+ city: zod.string().min(1, { message: 'Введите город!' }),
+ password: zod
+ .string()
+ .min(1, { message: 'Введите пароль!' })
+ .min(8, { message: 'Минимум 8 символов!' }),
+ passwordConfirm: zod.string().min(1, { message: 'Подтвердите пароль!' }),
+ })
+ .refine((d) => d.password === d.passwordConfirm, {
+ message: 'Пароли не совпадают!',
+ path: ['passwordConfirm'],
+ });
+
+// ----------------------------------------------------------------------
+
+function CityAutocomplete({ value, onChange, error, helperText }) {
+ const [inputValue, setInputValue] = useState(value || '');
+ const [options, setOptions] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const timerRef = useRef(null);
+
+ const fetch = useCallback(async (query) => {
+ if (!query || query.length < 2) { setOptions([]); return; }
+ setLoading(true);
+ try {
+ const res = await searchCities(query, 30);
+ setOptions(res.map((c) => (typeof c === 'string' ? c : c.name || c.city || String(c))));
+ } finally { setLoading(false); }
+ }, []);
+
+ useEffect(() => {
+ clearTimeout(timerRef.current);
+ timerRef.current = setTimeout(() => fetch(inputValue), 350);
+ return () => clearTimeout(timerRef.current);
+ }, [inputValue, fetch]);
+
+ return (
+ { setInputValue(val); onChange(val); }}
+ onChange={(_, val) => { if (val) { setInputValue(val); onChange(val); } }}
+ noOptionsText="Города не найдены"
+ loadingText="Поиск..."
+ renderInput={(params) => (
+
+ {loading ? : null}
+ {params.InputProps.endAdornment}
+ >
+ ),
+ }}
+ />
+ )}
+ />
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function InviteRegisterView({ token }) {
+ const router = useRouter();
+ const { checkUserSession } = useAuthContext();
+
+ const password = useBoolean();
+ const passwordConfirm = useBoolean();
+
+ const [mentorInfo, setMentorInfo] = useState(null);
+ const [infoLoading, setInfoLoading] = useState(true);
+ const [infoError, setInfoError] = useState('');
+ const [submitError, setSubmitError] = useState('');
+
+ useEffect(() => {
+ if (!token) { setInfoError('Токен отсутствует'); setInfoLoading(false); return; }
+ getInviteLinkInfo(token)
+ .then((data) => setMentorInfo(data))
+ .catch((e) => {
+ const msg = e?.response?.data?.error || e?.response?.data?.message || e?.message || 'Ссылка недействительна или истекла';
+ setInfoError(msg);
+ })
+ .finally(() => setInfoLoading(false));
+ }, [token]);
+
+ const methods = useForm({
+ resolver: zodResolver(RegisterSchema),
+ defaultValues: { firstName: '', lastName: '', email: '', city: '', password: '', passwordConfirm: '' },
+ });
+
+ const { control, handleSubmit, formState: { isSubmitting } } = methods; // eslint-disable-line
+
+ const onSubmit = handleSubmit(async (data) => {
+ try {
+ setSubmitError('');
+ const timezone = typeof Intl !== 'undefined'
+ ? Intl.DateTimeFormat().resolvedOptions().timeZone
+ : 'Europe/Moscow';
+
+ const res = await registerByInviteLink({
+ token,
+ first_name: data.firstName,
+ last_name: data.lastName,
+ email: data.email,
+ password: data.password,
+ city: data.city,
+ timezone,
+ });
+
+ // Авто-логин
+ await setSession(res.access, res.refresh);
+ await checkUserSession?.();
+ router.replace(paths.dashboard.root);
+ } catch (e) {
+ const msg = e?.response?.data?.error?.message
+ || e?.response?.data?.error
+ || e?.response?.data?.message
+ || e?.message
+ || 'Ошибка регистрации';
+ setSubmitError(msg);
+ }
+ });
+
+ if (infoLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (infoError) {
+ return (
+
+
+
+
+
+ Ссылка недействительна
+ {infoError}
+
+ Ссылка истекла, уже была использована или заблокирована. Попросите ментора создать новую ссылку.
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ {/* Mentor info */}
+
+
+ {mentorInfo?.mentor_name?.[0]}
+
+
+ Вас приглашает
+ {mentorInfo?.mentor_name}
+
+
+
+ Создать аккаунт
+
+ После регистрации вы сразу будете подключены к ментору
+
+
+ {submitError && {submitError}}
+
+
+
+
+
+
+ );
+}
diff --git a/front_minimal/src/sections/students/view/students-view.jsx b/front_minimal/src/sections/students/view/students-view.jsx
index 62bc8c0..9273d20 100644
--- a/front_minimal/src/sections/students/view/students-view.jsx
+++ b/front_minimal/src/sections/students/view/students-view.jsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useEffect, useCallback } from 'react';
+import { useRef, useState, useEffect, useCallback } from 'react';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';
@@ -15,10 +15,12 @@ import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import Divider from '@mui/material/Divider';
+import Tooltip from '@mui/material/Tooltip';
import ListItem from '@mui/material/ListItem';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import CardHeader from '@mui/material/CardHeader';
+import IconButton from '@mui/material/IconButton';
import DialogTitle from '@mui/material/DialogTitle';
import CardContent from '@mui/material/CardContent';
import ListItemText from '@mui/material/ListItemText';
@@ -31,12 +33,13 @@ import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths';
import { useRouter } from 'src/routes/hooks';
-import { resolveMediaUrl } from 'src/utils/axios';
+import { resolveMediaUrl } from 'src/utils/axios';
import {
getStudents,
getMyMentors,
getMyInvitations,
addStudentInvitation,
+ generateInvitationLink,
sendMentorshipRequest,
acceptMentorshipRequest,
rejectMentorshipRequest,
@@ -274,57 +277,226 @@ function MentorRequests({ onRefresh }) {
);
}
+// 8 отдельных полей для ввода кода
+function CodeInput({ value, onChange, disabled }) {
+ const refs = useRef([]);
+ const chars = (value || '').padEnd(8, '').split('').slice(0, 8);
+
+ const handleChange = (i, v) => {
+ const ch = v.toUpperCase().replace(/[^A-Z0-9]/g, '');
+ const next = chars.slice();
+ next[i] = ch[0] || '';
+ onChange(next.join('').trimEnd());
+ if (ch && i < 7) refs.current[i + 1]?.focus();
+ };
+
+ const handleKeyDown = (i, e) => {
+ if (e.key === 'Backspace' && !chars[i] && i > 0) refs.current[i - 1]?.focus();
+ if (e.key === 'ArrowLeft' && i > 0) refs.current[i - 1]?.focus();
+ if (e.key === 'ArrowRight' && i < 7) refs.current[i + 1]?.focus();
+ };
+
+ const handlePaste = (e) => {
+ e.preventDefault();
+ const text = e.clipboardData.getData('text').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
+ onChange(text);
+ refs.current[Math.min(text.length, 7)]?.focus();
+ };
+
+ return (
+
+ {chars.map((ch, i) => (
+ { refs.current[i] = el; }}
+ value={ch}
+ onChange={(e) => handleChange(i, e.target.value)}
+ onKeyDown={(e) => handleKeyDown(i, e)}
+ onPaste={i === 0 ? handlePaste : undefined}
+ disabled={disabled}
+ inputProps={{
+ maxLength: 1,
+ style: {
+ textAlign: 'center',
+ fontWeight: 700,
+ fontSize: 18,
+ letterSpacing: 0,
+ padding: '10px 0',
+ width: 32,
+ },
+ }}
+ sx={{ width: 40 }}
+ />
+ ))}
+
+ );
+}
+
function InviteDialog({ open, onClose, onSuccess }) {
- const [mode, setMode] = useState('email'); // 'email' | 'code'
- const [value, setValue] = useState('');
+ const [mode, setMode] = useState('email'); // 'email' | 'code' | 'link'
+ const [emailValue, setEmailValue] = useState('');
+ const [codeValue, setCodeValue] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
+ const [linkData, setLinkData] = useState(null); // { invitation_link, expires_at }
+ const [linkLoading, setLinkLoading] = useState(false);
+ const [copied, setCopied] = useState(false);
- const reset = () => { setValue(''); setError(null); setSuccessMsg(null); };
+ const reset = () => {
+ setEmailValue(''); setCodeValue(''); setError(null); setSuccessMsg(null);
+ setLinkData(null); setCopied(false);
+ };
+
+ const handleClose = () => { if (!loading && !linkLoading) { reset(); onClose(); } };
const handleSend = async () => {
- if (!value.trim()) return;
+ const val = mode === 'email' ? emailValue.trim() : codeValue.trim();
+ if (!val) return;
try {
setLoading(true);
setError(null);
- const payload = mode === 'email' ? { email: value.trim() } : { universal_code: value.trim() };
+ const payload = mode === 'email' ? { email: val } : { universal_code: val };
const res = await addStudentInvitation(payload);
setSuccessMsg(res?.message || 'Приглашение отправлено');
onSuccess();
} catch (e) {
- setError(e?.response?.data?.detail || e?.response?.data?.email?.[0] || e?.message || 'Ошибка');
+ const msg = e?.response?.data?.error?.message
+ || e?.response?.data?.detail
+ || e?.response?.data?.email?.[0]
+ || e?.message || 'Ошибка';
+ setError(msg);
} finally {
setLoading(false);
}
};
+ const handleGenerateLink = async () => {
+ try {
+ setLinkLoading(true);
+ setError(null);
+ const res = await generateInvitationLink();
+ setLinkData(res);
+ setCopied(false);
+ } catch (e) {
+ setError(e?.response?.data?.error?.message || e?.message || 'Ошибка генерации ссылки');
+ } finally {
+ setLinkLoading(false);
+ }
+ };
+
+ const handleCopyLink = () => {
+ navigator.clipboard.writeText(linkData.invitation_link);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ const expiresText = linkData?.expires_at
+ ? `Действует до ${new Date(linkData.expires_at).toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit' })}`
+ : '';
+
+ const canSend = mode === 'email' ? !!emailValue.trim() : codeValue.length === 8;
+
return (
-