diff --git a/backend/apps/referrals/tasks.py b/backend/apps/referrals/tasks.py
new file mode 100644
index 0000000..9e53528
--- /dev/null
+++ b/backend/apps/referrals/tasks.py
@@ -0,0 +1,38 @@
+from celery import shared_task
+from django.utils import timezone
+from django.db import transaction
+
+
+@shared_task
+def process_pending_referral_bonuses():
+ """Ежедневная обработка отложенных реферальных бонусов."""
+ from .models import PendingReferralBonus, UserActivityDay, UserReferralProfile
+
+ now = timezone.now()
+ paid_count = 0
+
+ for pending in PendingReferralBonus.objects.filter(
+ status=PendingReferralBonus.STATUS_PENDING
+ ).select_related('referrer', 'referred_user'):
+ referred_at = pending.referred_at
+ active_days = UserActivityDay.objects.filter(
+ user=pending.referred_user,
+ date__gte=referred_at.date(),
+ ).count()
+ days_since = (now - referred_at).days
+ if (days_since >= 30 and active_days >= 20) or active_days >= 21:
+ try:
+ with transaction.atomic():
+ profile = pending.referrer.referral_profile
+ profile.add_points(
+ pending.points,
+ reason=pending.reason or f'Реферал {pending.referred_user.email} выполнил условия'
+ )
+ pending.status = PendingReferralBonus.STATUS_PAID
+ pending.paid_at = now
+ pending.save(update_fields=['status', 'paid_at'])
+ paid_count += 1
+ except Exception:
+ pass
+
+ return f'Начислено бонусов: {paid_count}'
diff --git a/backend/apps/schedule/serializers.py b/backend/apps/schedule/serializers.py
index b8c0205..e8d827f 100644
--- a/backend/apps/schedule/serializers.py
+++ b/backend/apps/schedule/serializers.py
@@ -130,15 +130,7 @@ class LessonSerializer(serializers.ModelSerializer):
start_time = attrs.get('start_time')
duration = attrs.get('duration', 60)
-
- # Проверка: допускаем создание занятий до 30 минут в прошлом
- now = timezone.now()
- tolerance = timedelta(minutes=30)
- if start_time and start_time < now - tolerance:
- raise serializers.ValidationError({
- 'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
- })
-
+
# Проверка конфликтов (только при создании или изменении времени)
if self.instance is None or 'start_time' in attrs:
mentor = attrs.get('mentor') or self.instance.mentor if self.instance else None
@@ -198,7 +190,7 @@ class LessonDetailSerializer(LessonSerializer):
class LessonCreateSerializer(serializers.ModelSerializer):
"""Сериализатор для создания занятия."""
-
+
mentor = serializers.HiddenField(default=serializers.CurrentUserDefault())
subject_id = serializers.IntegerField(required=False, allow_null=True, source='subject')
mentor_subject_id = serializers.IntegerField(required=False, allow_null=True, source='mentor_subject')
@@ -212,6 +204,10 @@ class LessonCreateSerializer(serializers.ModelSerializer):
'title', 'description', 'subject_id', 'mentor_subject_id', 'subject_name', 'template', 'price',
'is_recurring'
]
+ extra_kwargs = {
+ 'client': {'required': False, 'allow_null': True},
+ 'group': {'required': False, 'allow_null': True},
+ }
def to_internal_value(self, data):
"""
@@ -307,6 +303,12 @@ class LessonCreateSerializer(serializers.ModelSerializer):
duration = attrs.get('duration', 60)
mentor = attrs.get('mentor')
client = attrs.get('client')
+ group = attrs.get('group')
+
+ if not client and not group:
+ raise serializers.ValidationError({
+ 'client': 'Необходимо указать ученика или группу.'
+ })
# Проверка что указан либо subject_id, либо mentor_subject_id, либо subject_name
# subject_id и mentor_subject_id приходят через source='subject' и source='mentor_subject'
@@ -380,20 +382,19 @@ class LessonCreateSerializer(serializers.ModelSerializer):
attrs['mentor_subject'] = mentor_subject
attrs['subject_name'] = mentor_subject.name
- # Проверка: допускаем создание занятий до 30 минут в прошлом
+ # Нормализуем start_time к UTC
if start_time:
if not django_timezone.is_aware(start_time):
start_time = pytz.UTC.localize(start_time)
elif start_time.tzinfo != pytz.UTC:
start_time = start_time.astimezone(pytz.UTC)
-
- now = django_timezone.now()
- tolerance = timedelta(minutes=30)
- if start_time < now - tolerance:
+
+ # Проверяем что занятие не начинается более 30 минут назад
+ if start_time < django_timezone.now() - timedelta(minutes=30):
raise serializers.ValidationError({
'start_time': 'Нельзя создать занятие, время начала которого было более 30 минут назад'
})
-
+
# Рассчитываем время окончания
end_time = start_time + timedelta(minutes=duration)
@@ -648,6 +649,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
"""Лёгкий сериализатор для календаря: только поля, нужные для списка и календаря."""
client_name = serializers.CharField(source='client.user.get_full_name', read_only=True)
mentor_name = serializers.CharField(source='mentor.get_full_name', read_only=True)
+ group_name = serializers.CharField(source='group.name', read_only=True, default=None)
subject = serializers.SerializerMethodField()
def get_subject(self, obj):
@@ -671,7 +673,7 @@ class LessonCalendarItemSerializer(serializers.ModelSerializer):
class Meta:
model = Lesson
- fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'subject', 'subject_name']
+ fields = ['id', 'title', 'start_time', 'end_time', 'status', 'client', 'client_name', 'mentor', 'mentor_name', 'group', 'group_name', 'subject', 'subject_name']
class LessonHomeworkSubmissionSerializer(serializers.ModelSerializer):
diff --git a/backend/apps/subscriptions/admin.py b/backend/apps/subscriptions/admin.py
index 55f7f26..66f839e 100644
--- a/backend/apps/subscriptions/admin.py
+++ b/backend/apps/subscriptions/admin.py
@@ -101,10 +101,13 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
'fields': ('name', 'slug', 'description')
}),
('Стоимость', {
- 'fields': ('price', 'price_per_student', 'currency', 'billing_period', 'subscription_type', 'trial_days'),
- 'description': 'Для типа "За ученика" укажите price_per_student. Для ежемесячной подписки - price. '
- 'Прогрессирующие скидки настраиваются ниже в разделе "Прогрессирующие скидки". '
- 'Доступные периоды оплаты определяются через скидки за длительность (см. раздел ниже).'
+ 'fields': ('price', 'price_per_student', 'currency', 'duration_days', 'subscription_type', 'trial_days'),
+ 'description': 'Укажите "Период оплаты (дней)" — именно столько дней будет действовать подписка (например: 30, 60, 90, 180, 365).'
+ }),
+ ('Устаревшие настройки', {
+ 'fields': ('billing_period',),
+ 'classes': ('collapse',),
+ 'description': 'Устаревшее поле. Используйте "Период оплаты (дней)" выше.'
}),
('Целевая аудитория', {
'fields': ('target_role',),
@@ -153,17 +156,11 @@ class SubscriptionPlanAdmin(admin.ModelAdmin):
price_display.short_description = 'Цена'
def billing_period_display(self, obj):
- """Отображение периода."""
- colors = {
- 'monthly': '#17a2b8',
- 'quarterly': '#28a745',
- 'yearly': '#ffc107',
- 'lifetime': '#6610f2'
- }
+ """Отображение периода в днях."""
+ days = obj.get_duration_days()
return format_html(
- '{}',
- colors.get(obj.billing_period, '#000'),
- obj.get_billing_period_display()
+ '{} дн.',
+ days
)
billing_period_display.short_description = 'Период'
diff --git a/backend/apps/subscriptions/migrations/0012_add_duration_days_to_plan.py b/backend/apps/subscriptions/migrations/0012_add_duration_days_to_plan.py
new file mode 100644
index 0000000..2ac3d6c
--- /dev/null
+++ b/backend/apps/subscriptions/migrations/0012_add_duration_days_to_plan.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.7 on 2026-03-12 20:35
+
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("subscriptions", "0011_add_target_role_to_subscription_plan"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="subscriptionplan",
+ name="duration_days",
+ field=models.IntegerField(
+ blank=True,
+ help_text='Количество дней действия подписки. Если указано, имеет приоритет над "Периодом оплаты".',
+ null=True,
+ validators=[django.core.validators.MinValueValidator(1)],
+ verbose_name="Длительность (дней)",
+ ),
+ ),
+ ]
diff --git a/backend/apps/subscriptions/models.py b/backend/apps/subscriptions/models.py
index b01aa51..087d70a 100644
--- a/backend/apps/subscriptions/models.py
+++ b/backend/apps/subscriptions/models.py
@@ -234,7 +234,8 @@ class SubscriptionPlan(models.Model):
max_length=20,
choices=BILLING_PERIOD_CHOICES,
default='monthly',
- verbose_name='Период оплаты'
+ verbose_name='Период оплаты (устарело)',
+ help_text='Устаревшее поле. Используйте "Период оплаты (дней)" ниже.'
)
subscription_type = models.CharField(
@@ -254,6 +255,14 @@ class SubscriptionPlan(models.Model):
help_text='Используется для типа "За ученика"'
)
+ duration_days = models.IntegerField(
+ null=True,
+ blank=True,
+ validators=[MinValueValidator(1)],
+ verbose_name='Период оплаты (дней)',
+ help_text='Количество дней действия подписки, например: 30, 60, 90, 180, 365.'
+ )
+
trial_days = models.IntegerField(
default=0,
validators=[MinValueValidator(0)],
@@ -426,16 +435,12 @@ class SubscriptionPlan(models.Model):
def get_duration_days(self, custom_days=None):
"""
Получить длительность подписки в днях.
-
- Args:
- custom_days: кастомная длительность в днях (30, 90, 180, 365)
-
- Returns:
- int: количество дней
+ Приоритет: custom_days → duration_days (поле модели) → billing_period.
"""
if custom_days:
return custom_days
-
+ if self.duration_days:
+ return self.duration_days
if self.billing_period == 'monthly':
return 30
elif self.billing_period == 'quarterly':
@@ -443,7 +448,7 @@ class SubscriptionPlan(models.Model):
elif self.billing_period == 'yearly':
return 365
elif self.billing_period == 'lifetime':
- return 36500 # 100 лет
+ return 36500
return 30
def get_available_durations(self):
diff --git a/backend/apps/subscriptions/serializers.py b/backend/apps/subscriptions/serializers.py
index ef19ce7..6f8326d 100644
--- a/backend/apps/subscriptions/serializers.py
+++ b/backend/apps/subscriptions/serializers.py
@@ -202,7 +202,7 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
plan_id = serializers.IntegerField()
student_count = serializers.IntegerField(default=0, min_value=0)
- duration_days = serializers.IntegerField(default=30, min_value=1)
+ duration_days = serializers.IntegerField(required=False, allow_null=True, min_value=1)
promo_code = serializers.CharField(required=False, allow_blank=True, allow_null=True)
start_date = serializers.DateTimeField(required=False, allow_null=True)
@@ -219,26 +219,9 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
return value
def validate_duration_days(self, value):
- """Валидация длительности."""
- allowed_durations = [30, 90, 180, 365]
- if value not in allowed_durations:
- raise serializers.ValidationError(
- f'Длительность должна быть одним из значений: {", ".join(map(str, allowed_durations))}'
- )
-
- # Проверяем доступность периода для плана
- plan_id = self.initial_data.get('plan_id')
- if plan_id:
- try:
- plan = SubscriptionPlan.objects.get(id=plan_id)
- if not plan.is_duration_available(value):
- available = plan.get_available_durations()
- raise serializers.ValidationError(
- f'Период {value} дней недоступен для этого тарифа. Доступные периоды: {", ".join(map(str, available))}'
- )
- except SubscriptionPlan.DoesNotExist:
- pass
-
+ """Валидация длительности — любое положительное число дней."""
+ if value is not None and value < 1:
+ raise serializers.ValidationError('Длительность должна быть не менее 1 дня')
return value
def validate_student_count(self, value):
@@ -256,7 +239,11 @@ class SubscriptionCreateSerializer(serializers.ModelSerializer):
plan = SubscriptionPlan.objects.get(id=plan_id)
except SubscriptionPlan.DoesNotExist:
raise serializers.ValidationError({'plan_id': 'Тарифный план не найден'})
-
+
+ # Если duration_days не передан — берём из плана
+ if not attrs.get('duration_days'):
+ attrs['duration_days'] = plan.get_duration_days()
+
# Проверяем доступность тарифа (акция)
user = self.context.get('request').user if self.context.get('request') else None
can_use, error_message = plan.can_be_used(user)
diff --git a/backend/apps/subscriptions/views.py b/backend/apps/subscriptions/views.py
index 3913985..62a11c9 100644
--- a/backend/apps/subscriptions/views.py
+++ b/backend/apps/subscriptions/views.py
@@ -326,15 +326,11 @@ class SubscriptionViewSet(viewsets.ModelViewSet):
{'error': 'Активация без оплаты доступна только для бесплатных тарифов (цена 0)'},
status=status.HTTP_400_BAD_REQUEST
)
- duration_days = int(request.data.get('duration_days', 30))
+ requested_days = request.data.get('duration_days')
+ duration_days = int(requested_days) if requested_days else plan.get_duration_days()
student_count = int(request.data.get('student_count', 1)) if st == 'per_student' else 0
if st == 'per_student' and student_count <= 0:
student_count = 1
- available = plan.get_available_durations() if hasattr(plan, 'get_available_durations') else [30]
- if not available:
- available = [30]
- if duration_days not in available:
- duration_days = available[0]
try:
subscription = SubscriptionService.create_subscription(
user=request.user,
@@ -788,9 +784,8 @@ class PaymentViewSet(viewsets.ModelViewSet):
status=status.HTTP_400_BAD_REQUEST
)
else:
- # По умолчанию используем первый доступный период или 30 дней
- available_durations = plan.get_available_durations()
- duration_days = available_durations[0] if available_durations else 30
+ # По умолчанию используем duration_days из тарифного плана
+ duration_days = plan.get_duration_days()
# Определяем количество учеников для тарифов "за ученика"
if plan.subscription_type == 'per_student':
diff --git a/backend/apps/users/dashboard_views.py b/backend/apps/users/dashboard_views.py
index 2c391a4..71e6253 100644
--- a/backend/apps/users/dashboard_views.py
+++ b/backend/apps/users/dashboard_views.py
@@ -73,7 +73,7 @@ class MentorDashboardViewSet(viewsets.ViewSet):
# Занятия - оптимизация: используем aggregate для всех подсчетов
from django.db.models import Count, Sum, Q
lessons = Lesson.objects.filter(mentor=user.id).select_related(
- 'mentor', 'client', 'client__user', 'subject', 'mentor_subject'
+ 'mentor', 'client', 'client__user', 'subject', 'mentor_subject', 'group'
)
# Один запрос для всех подсчетов занятий
@@ -89,9 +89,9 @@ class MentorDashboardViewSet(viewsets.ViewSet):
lessons_this_month = lessons_stats['this_month']
completed_lessons = lessons_stats['completed']
- # Ближайшие занятия
+ # Ближайшие занятия (включая начавшиеся в последние 90 мин, чтобы отображать кнопку «Подключиться»)
upcoming_lessons = lessons.filter(
- start_time__gte=now,
+ start_time__gte=now - timedelta(minutes=90),
status__in=['scheduled', 'in_progress']
).select_related('client', 'client__user', 'subject', 'mentor_subject').order_by('start_time')[:5]
@@ -163,6 +163,12 @@ class MentorDashboardViewSet(viewsets.ViewSet):
'avatar': request.build_absolute_uri(lesson.client.user.avatar.url) if lesson.client.user and lesson.client.user.avatar else None,
'first_name': lesson.client.user.first_name if lesson.client.user else '',
'last_name': lesson.client.user.last_name if lesson.client.user else ''
+ } if lesson.client_id else {
+ 'id': None,
+ 'name': lesson.group.name if lesson.group_id else 'Группа',
+ 'avatar': None,
+ 'first_name': '',
+ 'last_name': ''
},
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
@@ -589,7 +595,7 @@ class MentorDashboardViewSet(viewsets.ViewSet):
'target_name': (
item['group__name']
if item['group__name']
- else f"{item['client__user__first_name']} {item['client__user__last_name']}".strip() or 'Ученик'
+ else f"{item['client__user__last_name']} {item['client__user__first_name']}".strip() or 'Ученик'
),
'lessons_count': item['lessons_count'],
'total_income': float(item['total_income']),
@@ -649,9 +655,9 @@ class ClientDashboardViewSet(viewsets.ViewSet):
completed_lessons = lessons_stats['completed']
lessons_this_week = lessons_stats['this_week']
- # Ближайшие занятия с оптимизацией
+ # Ближайшие занятия с оптимизацией (включая начавшиеся в последние 90 мин)
upcoming_lessons = lessons.filter(
- start_time__gte=now,
+ start_time__gte=now - timedelta(minutes=90),
status__in=['scheduled', 'in_progress']
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
@@ -707,17 +713,18 @@ class ClientDashboardViewSet(viewsets.ViewSet):
'title': lesson.title,
'mentor': {
'id': lesson.mentor.id,
- 'name': lesson.mentor.get_full_name()
- },
+ 'first_name': lesson.mentor.first_name,
+ 'last_name': lesson.mentor.last_name,
+ 'name': lesson.mentor.get_full_name(),
+ 'avatar': request.build_absolute_uri(lesson.mentor.avatar.url) if lesson.mentor.avatar else None,
+ } if lesson.mentor_id else None,
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
}
for lesson in upcoming_lessons
]
}
-
- # Сохраняем в кеш на 2 минуты (120 секунд)
- # Кеш на 30 секунд для актуальности уведомлений
+
cache.set(cache_key, response_data, 30)
return Response(response_data)
@@ -1181,9 +1188,9 @@ class ParentDashboardViewSet(viewsets.ViewSet):
completed_lessons = lessons_stats['completed']
lessons_this_week = lessons_stats['this_week']
- # Ближайшие занятия
+ # Ближайшие занятия (включая начавшиеся в последние 90 мин)
upcoming_lessons = lessons.filter(
- start_time__gte=now,
+ start_time__gte=now - timedelta(minutes=90),
status__in=['scheduled', 'in_progress']
).select_related('mentor', 'client', 'client__user').order_by('start_time')[:5]
@@ -1239,17 +1246,19 @@ class ParentDashboardViewSet(viewsets.ViewSet):
'title': lesson.title,
'mentor': {
'id': lesson.mentor.id,
- 'name': lesson.mentor.get_full_name()
- },
+ 'first_name': lesson.mentor.first_name,
+ 'last_name': lesson.mentor.last_name,
+ 'name': lesson.mentor.get_full_name(),
+ 'avatar': request.build_absolute_uri(lesson.mentor.avatar.url) if lesson.mentor.avatar else None,
+ } if lesson.mentor_id else None,
'start_time': format_datetime_for_user(lesson.start_time, request.user.timezone) if lesson.start_time else None,
'end_time': format_datetime_for_user(lesson.end_time, request.user.timezone) if lesson.end_time else None
}
for lesson in upcoming_lessons
]
}
-
- # Сохраняем в кеш на 2 минуты (120 секунд)
+
cache.set(cache_key, response_data, 30)
-
+
return Response(response_data)
diff --git a/backend/apps/users/mentorship_views.py b/backend/apps/users/mentorship_views.py
index 7b7c8df..3fb526a 100644
--- a/backend/apps/users/mentorship_views.py
+++ b/backend/apps/users/mentorship_views.py
@@ -15,6 +15,7 @@ from apps.board.models import Board
def _apply_connection(conn):
"""После принятия связи: добавить ментора к студенту, создать доску."""
+ from django.core.cache import cache
student_user = conn.student
mentor = conn.mentor
try:
@@ -38,6 +39,10 @@ def _apply_connection(conn):
if conn.status != MentorStudentConnection.STATUS_ACCEPTED:
conn.status = MentorStudentConnection.STATUS_ACCEPTED
conn.save(update_fields=['status', 'updated_at'])
+ # Инвалидируем кэш списка студентов ментора
+ for page in range(1, 6):
+ for page_size in [10, 20, 50]:
+ cache.delete(f'manage_clients_{mentor.id}_{page}_{page_size}')
class MentorshipRequestViewSet(viewsets.ViewSet):
diff --git a/backend/apps/users/migrations/0012_add_group_to_board.py b/backend/apps/users/migrations/0012_add_group_to_board.py
new file mode 100644
index 0000000..f7c0f08
--- /dev/null
+++ b/backend/apps/users/migrations/0012_add_group_to_board.py
@@ -0,0 +1,24 @@
+# Generated by Django 4.2.7 on 2026-03-11 15:31
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0011_add_onboarding_tours_seen"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="user",
+ name="universal_code",
+ field=models.CharField(
+ blank=True,
+ help_text="8-символьный код (цифры и латинские буквы) для добавления ученика ментором",
+ max_length=8,
+ null=True,
+ unique=True,
+ verbose_name="Универсальный код",
+ ),
+ ),
+ ]
diff --git a/backend/apps/users/migrations/0014_add_removed_status.py b/backend/apps/users/migrations/0014_add_removed_status.py
new file mode 100644
index 0000000..0872f56
--- /dev/null
+++ b/backend/apps/users/migrations/0014_add_removed_status.py
@@ -0,0 +1,29 @@
+# Generated by Django 4.2.7 on 2026-03-12 21:03
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("users", "0013_invitation_link_model"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="mentorstudentconnection",
+ name="status",
+ field=models.CharField(
+ choices=[
+ ("pending_mentor", "Ожидает ответа ментора"),
+ ("pending_student", "Ожидает подтверждения студента"),
+ ("pending_parent", "Ожидает подтверждения родителя"),
+ ("accepted", "Принято"),
+ ("rejected", "Отклонено"),
+ ("removed", "Удалено"),
+ ],
+ db_index=True,
+ max_length=20,
+ verbose_name="Статус",
+ ),
+ ),
+ ]
diff --git a/backend/apps/users/models.py b/backend/apps/users/models.py
index 0d95754..12aaa02 100644
--- a/backend/apps/users/models.py
+++ b/backend/apps/users/models.py
@@ -567,6 +567,7 @@ class MentorStudentConnection(models.Model):
STATUS_PENDING_PARENT = 'pending_parent' # студент подтвердил, ждём родителя
STATUS_ACCEPTED = 'accepted'
STATUS_REJECTED = 'rejected'
+ STATUS_REMOVED = 'removed'
STATUS_CHOICES = [
(STATUS_PENDING_MENTOR, 'Ожидает ответа ментора'),
@@ -574,6 +575,7 @@ class MentorStudentConnection(models.Model):
(STATUS_PENDING_PARENT, 'Ожидает подтверждения родителя'),
(STATUS_ACCEPTED, 'Принято'),
(STATUS_REJECTED, 'Отклонено'),
+ (STATUS_REMOVED, 'Удалено'),
]
INITIATOR_STUDENT = 'student'
INITIATOR_MENTOR = 'mentor'
diff --git a/backend/apps/users/profile_views.py b/backend/apps/users/profile_views.py
index ee6fe5d..6c22158 100644
--- a/backend/apps/users/profile_views.py
+++ b/backend/apps/users/profile_views.py
@@ -608,7 +608,7 @@ class ProfileViewSet(viewsets.ViewSet):
continue
return Response(timezones_data)
- @action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny])
+ @action(detail=False, methods=['get'], url_path='cities/search', permission_classes=[AllowAny], authentication_classes=[])
def search_cities_from_csv(self, request):
"""
Поиск городов из city.csv по запросу.
@@ -921,16 +921,8 @@ class ClientManagementViewSet(viewsets.ViewSet):
status=status.HTTP_403_FORBIDDEN
)
- # Кеширование: кеш на 5 минут для каждого пользователя и страницы
- # Увеличено с 2 до 5 минут для ускорения повторных загрузок страницы "Студенты"
page = int(request.query_params.get('page', 1))
page_size = int(request.query_params.get('page_size', 20))
- cache_key = f'manage_clients_{user.id}_{page}_{page_size}'
-
- cached_data = cache.get(cache_key)
-
- if cached_data is not None:
- return Response(cached_data)
# ВАЖНО: оптимизация страницы "Студенты"
# Раньше ClientSerializer считал статистику занятий через 3 отдельных запроса на каждого клиента (N+1).
@@ -1026,9 +1018,6 @@ class ClientManagementViewSet(viewsets.ViewSet):
for inv in pending
]
- # Сохраняем в кеш на 5 минут (300 секунд) для ускорения повторных загрузок
- cache.set(cache_key, response_data.data, 300)
-
return response_data
@action(detail=False, methods=['get'], url_path='check-user')
@@ -1149,7 +1138,7 @@ class ClientManagementViewSet(viewsets.ViewSet):
defaults={
'status': MentorStudentConnection.STATUS_PENDING_STUDENT,
'initiator': MentorStudentConnection.INITIATOR_MENTOR,
- 'confirm_token': secrets.token_urlsafe(32) if is_new_user or True else None,
+ 'confirm_token': secrets.token_urlsafe(32),
}
)
if not created:
@@ -1161,6 +1150,13 @@ class ClientManagementViewSet(viewsets.ViewSet):
'message': 'Приглашение уже отправлено, ожидайте подтверждения',
'invitation_id': conn.id,
}, status=status.HTTP_200_OK)
+ # Связь была удалена — повторно отправляем приглашение
+ if conn.status == MentorStudentConnection.STATUS_REMOVED:
+ conn.status = MentorStudentConnection.STATUS_PENDING_STUDENT
+ conn.initiator = MentorStudentConnection.INITIATOR_MENTOR
+ conn.confirm_token = secrets.token_urlsafe(32)
+ conn.student_confirmed_at = None
+ conn.save(update_fields=['status', 'initiator', 'confirm_token', 'student_confirmed_at', 'updated_at'])
if not conn.confirm_token:
conn.confirm_token = secrets.token_urlsafe(32)
@@ -1230,18 +1226,39 @@ class ClientManagementViewSet(viewsets.ViewSet):
)
future_lessons_count = future_lessons.count()
future_lessons.delete()
-
- # Удаляем ментора (это автоматически запретит доступ ко всем материалам)
+
+ # Убираем доступ к доскам (не удаляем доски, только убираем участника)
+ from apps.board.models import Board
+ boards = Board.objects.filter(mentor=user, student=client.user)
+ for board in boards:
+ board.participants.remove(client.user)
+
+ # Закрываем активную связь
+ MentorStudentConnection.objects.filter(
+ mentor=user,
+ student=client.user,
+ status=MentorStudentConnection.STATUS_ACCEPTED
+ ).update(status='removed')
+
+ # Удаляем ментора (убирает доступ к материалам)
client.mentors.remove(user)
-
- # Инвалидируем кеш списка клиентов для этого ментора
- # Удаляем все варианты кеша для этого пользователя (разные страницы и размеры)
+
+ # Уведомление ученику
+ NotificationService.create_notification_with_telegram(
+ recipient=client.user,
+ notification_type='system',
+ title='Ментор завершил сотрудничество',
+ message=f'Ментор {user.get_full_name()} удалил вас из своего списка учеников. '
+ f'Будущие занятия отменены. Доступ к доскам приостановлен (при восстановлении связи — вернётся).',
+ data={'mentor_id': user.id}
+ )
+
for page in range(1, 10):
for size in [10, 20, 50, 100, 1000]:
cache.delete(f'manage_clients_{user.id}_{page}_{size}')
-
+
return Response({
- 'message': 'Клиент успешно удален',
+ 'message': 'Клиент успешно удалён',
'future_lessons_deleted': future_lessons_count
})
@@ -1288,6 +1305,82 @@ class ClientManagementViewSet(viewsets.ViewSet):
})
+class StudentMentorViewSet(viewsets.ViewSet):
+ """
+ Действия студента в отношении своих менторов.
+ remove_mentor: студент удаляет ментора из своего списка.
+ """
+ permission_classes = [IsAuthenticated]
+
+ @action(detail=True, methods=['delete'], url_path='remove')
+ def remove_mentor(self, request, pk=None):
+ """
+ Студент удаляет ментора.
+ DELETE /api/student/mentors/{mentor_id}/remove/
+ """
+ from django.utils import timezone
+ from apps.schedule.models import Lesson
+ from apps.board.models import Board
+
+ user = request.user
+ if user.role not in ('client', 'parent'):
+ return Response({'error': 'Только для учеников'}, status=status.HTTP_403_FORBIDDEN)
+
+ try:
+ mentor = User.objects.get(id=pk, role='mentor')
+ except User.DoesNotExist:
+ return Response({'error': 'Ментор не найден'}, status=status.HTTP_404_NOT_FOUND)
+
+ try:
+ client = user.client_profile
+ except Client.DoesNotExist:
+ return Response({'error': 'Профиль ученика не найден'}, status=status.HTTP_404_NOT_FOUND)
+
+ if mentor not in client.mentors.all():
+ return Response({'error': 'Ментор не связан с вами'}, status=status.HTTP_400_BAD_REQUEST)
+
+ # Удаляем будущие занятия
+ now = timezone.now()
+ future_lessons = Lesson.objects.filter(
+ mentor=mentor,
+ client=client,
+ start_time__gt=now,
+ status='scheduled'
+ )
+ future_lessons_count = future_lessons.count()
+ future_lessons.delete()
+
+ # Убираем доступ к доскам
+ boards = Board.objects.filter(mentor=mentor, student=user)
+ for board in boards:
+ board.participants.remove(user)
+
+ # Закрываем активную связь
+ MentorStudentConnection.objects.filter(
+ mentor=mentor,
+ student=user,
+ status=MentorStudentConnection.STATUS_ACCEPTED
+ ).update(status='removed')
+
+ # Удаляем ментора из профиля
+ client.mentors.remove(mentor)
+
+ # Уведомление ментору
+ NotificationService.create_notification_with_telegram(
+ recipient=mentor,
+ notification_type='system',
+ title='Ученик завершил сотрудничество',
+ message=f'Ученик {user.get_full_name()} удалил вас из своего списка менторов. '
+ f'Будущие занятия отменены. Доступ к доскам приостановлен.',
+ data={'student_id': user.id}
+ )
+
+ return Response({
+ 'message': 'Ментор успешно удалён',
+ 'future_lessons_deleted': future_lessons_count
+ })
+
+
class InvitationViewSet(viewsets.ViewSet):
"""
Подтверждение приглашений ментор—студент.
diff --git a/backend/apps/users/serializers.py b/backend/apps/users/serializers.py
index 68878ae..61f828a 100644
--- a/backend/apps/users/serializers.py
+++ b/backend/apps/users/serializers.py
@@ -129,7 +129,18 @@ class RegisterSerializer(serializers.ModelSerializer):
def validate_email(self, value):
"""Нормализация email в нижний регистр."""
return value.lower().strip() if value else value
-
+
+ def validate_timezone(self, value):
+ """Проверяем что timezone — валидный IANA идентификатор."""
+ if not value:
+ return 'Europe/Moscow'
+ import zoneinfo
+ try:
+ zoneinfo.ZoneInfo(value)
+ return value
+ except Exception:
+ return 'Europe/Moscow'
+
def validate(self, attrs):
"""Проверка совпадения паролей."""
if attrs.get('password') != attrs.get('password_confirm'):
diff --git a/backend/apps/users/signals.py b/backend/apps/users/signals.py
index 868422d..42b0d6a 100644
--- a/backend/apps/users/signals.py
+++ b/backend/apps/users/signals.py
@@ -1,11 +1,11 @@
"""
Сигналы для пользователей.
"""
-from django.db.models.signals import post_save, post_delete
+from django.db.models.signals import post_save, post_delete, m2m_changed
from django.dispatch import receiver
from django.core.cache import cache
-from .models import MentorStudentConnection
+from .models import MentorStudentConnection, Group
def _invalidate_manage_clients_cache(mentor_id):
@@ -28,3 +28,24 @@ def mentor_student_connection_changed(sender, instance, created, **kwargs):
if instance.mentor_id:
_invalidate_manage_clients_cache(instance.mentor_id)
+
+
+@receiver(post_save, sender=Group)
+def group_saved(sender, instance, created, **kwargs):
+ """При создании/обновлении группы — синхронизировать групповой чат."""
+ try:
+ from apps.chat.services import ChatService
+ ChatService.get_or_create_group_chat(instance)
+ except Exception:
+ pass
+
+
+@receiver(m2m_changed, sender=Group.students.through)
+def group_students_changed(sender, instance, action, **kwargs):
+ """При изменении участников группы — синхронизировать участников чата."""
+ if action in ('post_add', 'post_remove', 'post_clear'):
+ try:
+ from apps.chat.services import ChatService
+ ChatService.get_or_create_group_chat(instance)
+ except Exception:
+ pass
diff --git a/backend/apps/users/tasks.py b/backend/apps/users/tasks.py
index 335a8cb..42f7fcc 100644
--- a/backend/apps/users/tasks.py
+++ b/backend/apps/users/tasks.py
@@ -25,6 +25,7 @@ def send_welcome_email_task(user_id):
context = {
'user_full_name': user.get_full_name() or user.email,
'user_email': user.email,
+ 'login_url': f"{settings.FRONTEND_URL}/auth/jwt/sign-in",
}
# Загружаем HTML и текстовые шаблоны
@@ -60,7 +61,7 @@ def send_verification_email_task(user_id, verification_token):
user = User.objects.get(id=user_id)
# URL для подтверждения
- verification_url = f"{settings.FRONTEND_URL}/verify-email?token={verification_token}"
+ verification_url = f"{settings.FRONTEND_URL}/auth/jwt/verify-email?token={verification_token}"
subject = 'Подтвердите ваш email'
@@ -102,7 +103,7 @@ def send_password_reset_email_task(user_id, reset_token):
user = User.objects.get(id=user_id)
# URL для сброса пароля
- reset_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
+ reset_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}"
subject = 'Восстановление пароля'
@@ -144,7 +145,7 @@ def send_student_welcome_email_task(user_id, reset_token):
user = User.objects.get(id=user_id)
# URL для установки пароля
- set_password_url = f"{settings.FRONTEND_URL}/reset-password?token={reset_token}"
+ set_password_url = f"{settings.FRONTEND_URL}/auth/jwt/reset-password?token={reset_token}"
subject = 'Добро пожаловать на платформу!'
diff --git a/backend/apps/users/templates/emails/base.html b/backend/apps/users/templates/emails/base.html
index dedf85d..02d27f1 100644
--- a/backend/apps/users/templates/emails/base.html
+++ b/backend/apps/users/templates/emails/base.html
@@ -4,61 +4,42 @@
-
{% block title %}Uchill{% endblock %}
-
+ {% block title %}Училл{% endblock %}
+
-
-
-
+
+
+
+
+
+
+
+
-
-
-
-
-
- |
-
-
- |
-
-
-
-
- |
- {% block content %}{% endblock %}
- |
-
-
-
-
-
-
-
- |
- С уважением, Команда Uchill
-
- © {% now "Y" %} Uchill. Все права защищены.
-
- |
-
-
- |
-
-
+ |
+ Училл
+
+ Платформа для обучения
|
+
+
+
+ |
+ {% block content %}{% endblock %}
+ |
+
+
+
+
+ |
+ С уважением, Команда Училл
+ © {% now "Y" %} Училл. Все права защищены.
+ |
+
+
+ |
+
diff --git a/backend/apps/users/templates/emails/mentor_invitation.html b/backend/apps/users/templates/emails/mentor_invitation.html
index e9c2cc2..1ba250c 100644
--- a/backend/apps/users/templates/emails/mentor_invitation.html
+++ b/backend/apps/users/templates/emails/mentor_invitation.html
@@ -4,167 +4,111 @@
- Приглашение от ментора - Uchill
-
+ Приглашение от ментора — Училл
+
-
-
-
-
-
-
-
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
- Приглашение от ментора
- |
-
-
-
-
- |
-
- Здравствуйте!
-
- |
-
-
-
-
- |
-
- {{ mentor_name }} приглашает вас в качестве ученика на платформу Uchill.
-
- |
-
-
- {% if set_password_url %}
-
-
- |
-
- Для начала работы установите пароль и подтвердите приглашение.
-
- |
-
-
-
-
- |
-
- |
-
-
-
-
-
-
-
- |
-
- {{ set_password_url }}
-
- |
-
-
- |
-
-
- {% elif confirm_url %}
-
-
- |
-
- Подтвердите приглашение, чтобы начать занятия с ментором.
-
- |
-
-
-
-
- |
-
- |
-
-
-
-
-
-
-
- |
-
- {{ confirm_url }}
-
- |
-
-
- |
-
- {% endif %}
-
- |
-
-
-
-
-
-
-
- |
- С уважением, Команда Uchill
-
- © 2026 Uchill. Все права защищены.
-
- |
-
-
- |
-
-
- |
-
-
+
+
+
+
+
+
+
+
+ Училл
+ Платформа для обучения
+ |
+
+
+
+
+
+
+
+
+
+ Вас приглашают учиться!
+ Личное приглашение от ментора
+
+
+ Здравствуйте!
+
+
+
+
+ |
+ Ментор
+ {{ mentor_name }}
+ приглашает вас на платформу Училл
+ |
+
+
+ {% if set_password_url %}
+
+ Для начала занятий установите пароль и подтвердите приглашение — это займёт меньше минуты.
+
+
+
+
+
+
+
+ |
+ Или скопируйте ссылку:
+ {{ set_password_url }}
+ |
+
+
+ {% elif confirm_url %}
+
+ Подтвердите приглашение, чтобы начать занятия с ментором.
+
+
+
+
+
+
+
+ |
+ Или скопируйте ссылку:
+ {{ confirm_url }}
+ |
+
+ {% endif %}
+
+ |
+
+
+
+
+ |
+ С уважением, Команда Училл
+ © {% now "Y" %} Училл. Все права защищены.
+ |
+
+
+
+ |
+
diff --git a/backend/apps/users/templates/emails/password_reset.html b/backend/apps/users/templates/emails/password_reset.html
index 8f27f95..34e68b1 100644
--- a/backend/apps/users/templates/emails/password_reset.html
+++ b/backend/apps/users/templates/emails/password_reset.html
@@ -4,143 +4,93 @@
- Восстановление пароля - Uchill
-
+ Восстановление пароля — Училл
+
-
-
-
-
-
-
-
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
- Восстановление пароля
- |
-
-
-
-
- |
-
- Здравствуйте, {{ user_full_name }}!
-
- |
-
-
-
-
- |
-
- Вы запросили восстановление пароля для вашего аккаунта. Нажмите на кнопку ниже, чтобы установить новый пароль.
-
- |
-
-
-
-
- |
-
- |
-
-
-
-
- |
-
- Или скопируйте и вставьте эту ссылку в браузер:
-
-
-
- |
-
- {{ reset_url }}
-
- |
-
-
- |
-
-
-
-
-
-
-
- |
-
- ⚠️ Важно: Ссылка действительна в течение 24 часов.
-
- |
-
-
- |
-
-
-
-
- |
-
- Если вы не запрашивали восстановление пароля, просто проигнорируйте это письмо. Ваш пароль останется без изменений.
-
- |
-
-
- |
-
-
-
-
-
-
-
- |
- С уважением, Команда Uchill
-
- © 2026 Uchill. Все права защищены.
-
- |
-
-
- |
-
-
- |
-
-
+
+
+
+
+
+
+
+
+ Училл
+ Платформа для обучения
+ |
+
+
+
+
+
+
+
+
+
+ Восстановление пароля
+ Мы получили запрос на сброс пароля
+
+
+ Здравствуйте, {{ user_full_name }}!
+
+
+ Нажмите на кнопку ниже, чтобы установить новый пароль для вашего аккаунта.
+
+
+
+
+
+
+
+ |
+ Или скопируйте ссылку:
+ {{ reset_url }}
+ |
+
+
+
+
+ |
+
+ Важно: ссылка действительна в течение 24 часов.
+
+ |
+
+
+
+
+ |
+
+ Если вы не запрашивали восстановление пароля — просто проигнорируйте это письмо. Ваш пароль останется без изменений.
+
+ |
+
+
+ |
+
+
+
+
+ |
+ С уважением, Команда Училл
+ © {% now "Y" %} Училл. Все права защищены.
+ |
+
+
+
+ |
+
diff --git a/backend/apps/users/templates/emails/student_welcome.html b/backend/apps/users/templates/emails/student_welcome.html
index 0014089..60c4f39 100644
--- a/backend/apps/users/templates/emails/student_welcome.html
+++ b/backend/apps/users/templates/emails/student_welcome.html
@@ -4,152 +4,92 @@
- Добро пожаловать на Uchill
-
+ Добро пожаловать на Училл
+
-
-
-
-
-
-
-
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
- Добро пожаловать!
- |
-
-
-
-
- |
-
- Здравствуйте, {{ user_full_name }}!
-
- |
-
-
-
-
- |
-
- Вас добавили на Uchill. Для начала работы необходимо установить пароль для вашего аккаунта.
-
- |
-
-
-
-
-
-
-
- |
-
- Ваш email для входа:
-
-
- {{ user_email }}
-
- |
-
-
- |
-
-
-
-
- |
-
- |
-
-
-
-
- |
-
- Или скопируйте и вставьте эту ссылку в браузер:
-
-
-
- |
-
- {{ set_password_url }}
-
- |
-
-
- |
-
-
-
-
-
-
-
- |
-
- ⚠️ Важно: Ссылка действительна в течение 7 дней.
-
- |
-
-
- |
-
-
- |
-
-
-
-
-
-
-
- |
- С уважением, Команда Uchill
-
- © 2026 Uchill. Все права защищены.
-
- |
-
-
- |
-
-
- |
-
-
+
+
+
+
+
+
+
+
+ Училл
+ Платформа для обучения
+ |
+
+
+
+
+
+
+
+
+
+ Добро пожаловать!
+ Ваш аккаунт на Училл создан
+
+
+ Здравствуйте, {{ user_full_name }}!
+
+
+ Ваш ментор добавил вас на платформу Училл. Для начала работы установите пароль для вашего аккаунта.
+
+
+
+
+ |
+ Ваш email для входа
+ {{ user_email }}
+ |
+
+
+
+
+
+
+
+ |
+ Или скопируйте ссылку:
+ {{ set_password_url }}
+ |
+
+
+
+
+ |
+
+ Важно: ссылка действительна в течение 7 дней.
+
+ |
+
+
+ |
+
+
+
+
+ |
+ С уважением, Команда Училл
+ © {% now "Y" %} Училл. Все права защищены.
+ |
+
+
+
+ |
+
diff --git a/backend/apps/users/templates/emails/verification.html b/backend/apps/users/templates/emails/verification.html
index 1f2fa90..c2f1846 100644
--- a/backend/apps/users/templates/emails/verification.html
+++ b/backend/apps/users/templates/emails/verification.html
@@ -4,128 +4,84 @@
- Подтверждение email - Uchill
-
+ Подтверждение email — Училл
+
-
-
-
-
-
-
-
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
- Подтверждение email
- |
-
-
-
-
- |
-
- Здравствуйте, {{ user_full_name }}!
-
- |
-
-
-
-
- |
-
- Спасибо за регистрацию на Uchill. Для завершения регистрации необходимо подтвердить ваш email адрес.
-
- |
-
-
-
-
- |
-
- |
-
-
-
-
- |
-
- Или скопируйте и вставьте эту ссылку в браузер:
-
-
-
- |
-
- {{ verification_url }}
-
- |
-
-
- |
-
-
-
-
- |
-
- Если вы не регистрировались на нашей платформе, просто проигнорируйте это письмо.
-
- |
-
-
- |
-
-
-
-
-
-
-
- |
- С уважением, Команда Uchill
-
- © 2026 Uchill. Все права защищены.
-
- |
-
-
- |
-
-
- |
-
-
+
+
+
+
+
+
+
+
+ Училл
+ Платформа для обучения
+ |
+
+
+
+
+
+
+
+
+
+ Подтвердите ваш email
+ Осталось всего один шаг!
+
+
+ Здравствуйте, {{ user_full_name }}!
+
+
+ Спасибо за регистрацию на Училл. Нажмите на кнопку ниже, чтобы подтвердить ваш адрес электронной почты и активировать аккаунт.
+
+
+
+
+
+
+
+ |
+ Или скопируйте ссылку:
+ {{ verification_url }}
+ |
+
+
+
+
+ |
+
+ Если вы не регистрировались на Училл — просто проигнорируйте это письмо.
+
+ |
+
+
+ |
+
+
+
+
+ |
+ С уважением, Команда Училл
+ © {% now "Y" %} Училл. Все права защищены.
+ |
+
+
+
+ |
+
diff --git a/backend/apps/users/templates/emails/welcome.html b/backend/apps/users/templates/emails/welcome.html
index 75eb27a..dcff102 100644
--- a/backend/apps/users/templates/emails/welcome.html
+++ b/backend/apps/users/templates/emails/welcome.html
@@ -4,128 +4,106 @@
- Добро пожаловать на Uchill
-
+ Добро пожаловать на Училл
+
-
-
-
-
-
-
-
-
-
- |
-
-
- |
-
-
-
-
-
-
-
-
-
- Добро пожаловать!
- |
-
-
-
-
- |
-
- Здравствуйте, {{ user_full_name }}!
-
- |
-
-
-
-
- |
-
- Добро пожаловать на Uchill! Ваш аккаунт успешно создан.
-
- |
-
-
-
-
-
-
-
- |
-
- Ваш email для входа:
-
-
- {{ user_email }}
-
- |
-
-
- |
-
-
-
-
- |
-
- Теперь вы можете войти в систему и начать пользоваться всеми возможностями платформы.
-
- |
-
-
-
-
- |
-
- |
-
-
- |
-
-
-
-
-
-
-
- |
- С уважением, Команда Uchill
-
- © 2026 Uchill. Все права защищены.
-
- |
-
-
- |
-
-
- |
-
-
+
+
+
+
+
+
+
+
+ Училл
+ Платформа для обучения
+ |
+
+
+
+
+
+
+
+
+
+ Добро пожаловать на Училл!
+ Ваш аккаунт успешно создан
+
+
+ Здравствуйте, {{ user_full_name }}!
+
+
+ Вы успешно зарегистрировались на платформе Училл. Теперь у вас есть доступ ко всем возможностям для обучения.
+
+
+
+
+ |
+ Ваш email для входа
+ {{ user_email }}
+ |
+
+
+
+
+
+
+
+
+ | 📅 Онлайн-расписание занятий |
+
+
+ |
+
+
+
+
+
+ | 📹 Видеозвонки с интерактивной доской |
+
+
+ |
+
+
+
+
+
+ | 📚 Домашние задания и материалы |
+
+
+ |
+
+
+
+
+
+
+ |
+
+
+
+
+ |
+ С уважением, Команда Училл
+ © {% now "Y" %} Училл. Все права защищены.
+ |
+
+
+
+ |
+
diff --git a/backend/apps/users/urls.py b/backend/apps/users/urls.py
index 5c8ba50..ac5b02f 100644
--- a/backend/apps/users/urls.py
+++ b/backend/apps/users/urls.py
@@ -32,6 +32,7 @@ from .profile_views import (
ClientManagementViewSet,
ParentManagementViewSet,
InvitationViewSet,
+ StudentMentorViewSet,
)
from .mentorship_views import MentorshipRequestViewSet
from .student_progress_views import StudentProgressViewSet
@@ -53,6 +54,7 @@ router.register(r'parent', ParentDashboardViewSet, basename='parent-dashboard')
router.register(r'profile', ProfileViewSet, basename='profile')
router.register(r'manage/clients', ClientManagementViewSet, basename='manage-clients')
router.register(r'invitation', InvitationViewSet, basename='invitation')
+router.register(r'student/mentors', StudentMentorViewSet, basename='student-mentors')
router.register(r'mentorship-requests', MentorshipRequestViewSet, basename='mentorship-request')
router.register(r'manage/parents', ParentManagementViewSet, basename='manage-parents')
diff --git a/backend/apps/users/views.py b/backend/apps/users/views.py
index 58365d7..87d8542 100644
--- a/backend/apps/users/views.py
+++ b/backend/apps/users/views.py
@@ -42,10 +42,11 @@ from config.throttling import BurstRateThrottle
class TelegramBotInfoView(generics.GenericAPIView):
"""
API endpoint для получения информации о Telegram боте.
-
+
GET /api/auth/telegram/bot-info/
"""
permission_classes = [AllowAny]
+ authentication_classes = []
def get(self, request, *args, **kwargs):
"""Получение имени бота для использования в Telegram Login Widget."""
@@ -69,11 +70,12 @@ class TelegramBotInfoView(generics.GenericAPIView):
class TelegramAuthView(generics.GenericAPIView):
"""
API endpoint для авторизации через Telegram Login Widget.
-
+
POST /api/auth/telegram/
"""
serializer_class = TelegramAuthSerializer
permission_classes = [AllowAny]
+ authentication_classes = []
throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs):
@@ -149,12 +151,13 @@ class TelegramAuthView(generics.GenericAPIView):
class RegisterView(generics.CreateAPIView):
"""
API endpoint для регистрации нового пользователя.
-
+
POST /api/auth/register/
"""
queryset = User.objects.all()
serializer_class = RegisterSerializer
permission_classes = [AllowAny]
+ authentication_classes = []
throttle_classes = [BurstRateThrottle]
def create(self, request, *args, **kwargs):
@@ -194,11 +197,12 @@ class RegisterView(generics.CreateAPIView):
class LoginView(generics.GenericAPIView):
"""
API endpoint для входа пользователя.
-
+
POST /api/auth/login/
"""
serializer_class = LoginSerializer
permission_classes = [AllowAny]
+ authentication_classes = []
throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs):
@@ -240,10 +244,11 @@ class LoginView(generics.GenericAPIView):
class LoginByTokenView(generics.GenericAPIView):
"""
API endpoint для входа по персональному токену.
-
+
POST /api/auth/login-by-token/
"""
permission_classes = [AllowAny]
+ authentication_classes = []
throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs):
@@ -348,11 +353,12 @@ class ChangePasswordView(generics.GenericAPIView):
class PasswordResetRequestView(generics.GenericAPIView):
"""
API endpoint для запроса восстановления пароля.
-
+
POST /api/auth/password-reset/
"""
serializer_class = PasswordResetRequestSerializer
permission_classes = [AllowAny]
+ authentication_classes = []
throttle_classes = [BurstRateThrottle]
def post(self, request, *args, **kwargs):
@@ -385,11 +391,12 @@ class PasswordResetRequestView(generics.GenericAPIView):
class PasswordResetConfirmView(generics.GenericAPIView):
"""
API endpoint для подтверждения восстановления пароля.
-
+
POST /api/auth/password-reset-confirm/
"""
serializer_class = PasswordResetConfirmSerializer
permission_classes = [AllowAny]
+ authentication_classes = []
def post(self, request, *args, **kwargs):
"""Подтверждение восстановления пароля."""
@@ -421,11 +428,12 @@ class PasswordResetConfirmView(generics.GenericAPIView):
class EmailVerificationView(generics.GenericAPIView):
"""
API endpoint для подтверждения email.
-
+
POST /api/auth/verify-email/
"""
serializer_class = EmailVerificationSerializer
permission_classes = [AllowAny]
+ authentication_classes = []
def post(self, request, *args, **kwargs):
"""Подтверждение email пользователя."""
@@ -459,11 +467,12 @@ class EmailVerificationView(generics.GenericAPIView):
class ResendVerificationEmailView(generics.GenericAPIView):
"""
API endpoint для повторной отправки письма подтверждения email.
-
+
POST /api/auth/resend-verification/
Можно использовать с авторизацией или без (передавая email)
"""
permission_classes = [AllowAny]
+ authentication_classes = []
def post(self, request, *args, **kwargs):
"""Повторная отправка письма подтверждения email."""
@@ -806,8 +815,8 @@ class GroupViewSet(viewsets.ModelViewSet):
distinct=True
)
).only(
- 'id', 'name', 'description', 'mentor_id', 'max_students',
- 'is_active', 'created_at', 'updated_at'
+ 'id', 'name', 'description', 'mentor_id',
+ 'created_at'
)
return queryset
diff --git a/backend/config/celery.py b/backend/config/celery.py
index dd689a2..3fd9454 100644
--- a/backend/config/celery.py
+++ b/backend/config/celery.py
@@ -159,6 +159,16 @@ app.conf.beat_schedule = {
'task': 'apps.materials.tasks.cleanup_old_unused_materials',
'schedule': crontab(day_of_week=0, hour=3, minute=0), # Воскресенье в 3:00
},
+
+ # ============================================
+ # РЕФЕРАЛЬНАЯ СИСТЕМА
+ # ============================================
+
+ # Обработка отложенных реферальных бонусов каждый день в 6:00
+ 'process-pending-referral-bonuses': {
+ 'task': 'apps.referrals.tasks.process_pending_referral_bonuses',
+ 'schedule': crontab(hour=6, minute=0),
+ },
}
@app.task(bind=True, ignore_result=True)
diff --git a/front_minimal/index.html b/front_minimal/index.html
index 76b1d6c..f5be6a3 100644
--- a/front_minimal/index.html
+++ b/front_minimal/index.html
@@ -2,9 +2,9 @@
-
+
- Platform
+ Училл
diff --git a/front_minimal/src/actions/calendar.js b/front_minimal/src/actions/calendar.js
index a5c1cf5..06d6113 100644
--- a/front_minimal/src/actions/calendar.js
+++ b/front_minimal/src/actions/calendar.js
@@ -169,14 +169,11 @@ export function useGetGroups() {
// ----------------------------------------------------------------------
-function revalidateCalendar(date) {
- const d = date || new Date();
- const start = format(startOfMonth(subMonths(d, 1)), 'yyyy-MM-dd');
- const end = format(endOfMonth(addMonths(d, 1)), 'yyyy-MM-dd');
- mutate(['calendar', start, end]);
+function revalidateCalendar() {
+ mutate((key) => Array.isArray(key) && key[0] === 'calendar', undefined, { revalidate: true });
}
-export async function createEvent(eventData, currentDate) {
+export async function createEvent(eventData) {
const isGroup = !!eventData.group;
const payload = {
title: eventData.title || 'Занятие',
@@ -190,7 +187,7 @@ export async function createEvent(eventData, currentDate) {
};
const res = await createCalendarLesson(payload);
- revalidateCalendar(currentDate);
+ revalidateCalendar();
return res;
}
@@ -205,11 +202,11 @@ export async function updateEvent(eventData, currentDate) {
if (data.status) updatePayload.status = data.status;
const res = await updateCalendarLesson(String(id), updatePayload);
- revalidateCalendar(currentDate);
+ revalidateCalendar();
return res;
}
-export async function deleteEvent(eventId, deleteAllFuture = false, currentDate) {
+export async function deleteEvent(eventId, deleteAllFuture = false) {
await deleteCalendarLesson(String(eventId), deleteAllFuture);
- revalidateCalendar(currentDate);
+ revalidateCalendar();
}
diff --git a/front_minimal/src/auth/context/jwt/action.js b/front_minimal/src/auth/context/jwt/action.js
index f7ad38b..82fb788 100644
--- a/front_minimal/src/auth/context/jwt/action.js
+++ b/front_minimal/src/auth/context/jwt/action.js
@@ -42,19 +42,10 @@ export const signUp = async ({ email, password, passwordConfirm, firstName, last
timezone: timezone || (typeof Intl !== 'undefined' ? Intl.DateTimeFormat().resolvedOptions().timeZone : 'Europe/Moscow'),
};
- const res = await axios.post(endpoints.auth.signUp, params);
+ await axios.post(endpoints.auth.signUp, params);
- const data = res.data?.data;
- const accessToken = data?.tokens?.access;
- const refreshToken = data?.tokens?.refresh;
-
- if (!accessToken) {
- // Регистрация прошла, но токен не выдан (требуется верификация email)
- return { requiresVerification: true };
- }
-
- await setSession(accessToken, refreshToken);
- return { requiresVerification: false };
+ // Всегда требуем подтверждение email перед входом
+ return { requiresVerification: true };
} catch (error) {
console.error('Error during sign up:', error);
throw error;
diff --git a/front_minimal/src/auth/guard/subscription-guard.jsx b/front_minimal/src/auth/guard/subscription-guard.jsx
new file mode 100644
index 0000000..6977091
--- /dev/null
+++ b/front_minimal/src/auth/guard/subscription-guard.jsx
@@ -0,0 +1,58 @@
+import { useEffect, useState } from 'react';
+
+import { useRouter, usePathname } from 'src/routes/hooks';
+import { paths } from 'src/routes/paths';
+
+import { useAuthContext } from '../hooks';
+import axios from 'src/utils/axios';
+
+// Страницы, доступные без подписки
+const EXEMPT_PATHS = [
+ paths.dashboard.payment,
+ paths.dashboard.profile,
+ paths.dashboard.referrals,
+ paths.dashboard.notifications,
+];
+
+// ----------------------------------------------------------------------
+
+export function SubscriptionGuard({ children }) {
+ const { user, loading } = useAuthContext();
+ const router = useRouter();
+ const pathname = usePathname();
+ const [checked, setChecked] = useState(false);
+
+ useEffect(() => {
+ if (loading) return;
+
+ // Только для менторов
+ if (!user || user.role !== 'mentor') {
+ setChecked(true);
+ return;
+ }
+
+ // Страница оплаты и некоторые другие не требуют подписки
+ if (EXEMPT_PATHS.some((p) => pathname.startsWith(p))) {
+ setChecked(true);
+ return;
+ }
+
+ axios
+ .get('/subscriptions/subscriptions/active/')
+ .then((res) => {
+ if (res.data) {
+ setChecked(true);
+ } else {
+ router.replace(paths.dashboard.payment);
+ }
+ })
+ .catch(() => {
+ router.replace(paths.dashboard.payment);
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [user, loading, pathname]);
+
+ if (!checked) return null;
+
+ return <>{children}>;
+}
diff --git a/front_minimal/src/components/chart/use-chart.js b/front_minimal/src/components/chart/use-chart.js
index 31f5767..2facf34 100644
--- a/front_minimal/src/components/chart/use-chart.js
+++ b/front_minimal/src/components/chart/use-chart.js
@@ -67,8 +67,9 @@ export function useChart(options) {
animations: {
enabled: true,
speed: 360,
+ easing: 'easeinout',
animateGradually: { enabled: true, delay: 120 },
- dynamicAnimation: { enabled: true, speed: 360 },
+ dynamicAnimation: { enabled: true, speed: 400, easing: 'easeinout' },
...options?.chart?.animations,
},
},
diff --git a/front_minimal/src/components/custom-popover/custom-popover.jsx b/front_minimal/src/components/custom-popover/custom-popover.jsx
index 26c5fd1..5f84382 100644
--- a/front_minimal/src/components/custom-popover/custom-popover.jsx
+++ b/front_minimal/src/components/custom-popover/custom-popover.jsx
@@ -21,6 +21,7 @@ export function CustomPopover({ open, onClose, children, anchorEl, slotProps, ..
open={!!open}
anchorEl={anchorEl}
onClose={onClose}
+ disableScrollLock
anchorOrigin={anchorOrigin}
transformOrigin={transformOrigin}
slotProps={{
diff --git a/front_minimal/src/components/logo/logo.jsx b/front_minimal/src/components/logo/logo.jsx
index dc1f7ae..e6f7604 100644
--- a/front_minimal/src/components/logo/logo.jsx
+++ b/front_minimal/src/components/logo/logo.jsx
@@ -2,12 +2,9 @@
import { forwardRef } from 'react';
import Box from '@mui/material/Box';
-import NoSsr from '@mui/material/NoSsr';
+import { Link } from 'react-router-dom';
import { CONFIG } from 'src/config-global';
-import { RouterLink } from 'src/routes/components';
-
-import { logoClasses } from './classes';
// ----------------------------------------------------------------------
@@ -16,7 +13,7 @@ import { logoClasses } from './classes';
* mini=true → icon only (favicon.png)
*/
export const Logo = forwardRef(
- ({ width, height, mini = false, disableLink = false, className, href = '/', sx, ...other }, ref) => {
+ ({ width, height, mini = false, disableLink = false, className, href = '/dashboard', sx, ...other }, ref) => {
const defaultWidth = mini ? 40 : 134;
const defaultHeight = mini ? 40 : 40;
@@ -40,37 +37,34 @@ export const Logo = forwardRef(
/>
);
- return (
-
- }
- >
-
+ const style = {
+ flexShrink: 0,
+ display: 'inline-flex',
+ verticalAlign: 'middle',
+ width: w,
+ height: h,
+ ...(disableLink && { pointerEvents: 'none' }),
+ };
+
+ if (disableLink) {
+ return (
+
{logo}
-
+ );
+ }
+
+ return (
+
+ {logo}
+
);
}
);
diff --git a/front_minimal/src/components/nav-section/vertical/nav-list.jsx b/front_minimal/src/components/nav-section/vertical/nav-list.jsx
index ebdd2be..6ea7e70 100644
--- a/front_minimal/src/components/nav-section/vertical/nav-list.jsx
+++ b/front_minimal/src/components/nav-section/vertical/nav-list.jsx
@@ -49,7 +49,7 @@ export function NavList({ data, render, depth, slotProps, enabledRootRedirect })
disabled={data.disabled}
hasChild={!!data.children}
open={data.children && openMenu}
- externalLink={isExternalLink(data.path)}
+ externalLink={data.externalLink || isExternalLink(data.path)}
enabledRootRedirect={enabledRootRedirect}
// styles
slotProps={depth === 1 ? slotProps?.rootItem : slotProps?.subItem}
diff --git a/front_minimal/src/global.css b/front_minimal/src/global.css
index f7f31a8..4895cd2 100644
--- a/front_minimal/src/global.css
+++ b/front_minimal/src/global.css
@@ -57,6 +57,7 @@
*************************************** */
html {
height: 100%;
+ overflow-x: hidden;
-webkit-overflow-scrolling: touch;
}
body,
diff --git a/front_minimal/src/hooks/use-page-title.js b/front_minimal/src/hooks/use-page-title.js
new file mode 100644
index 0000000..f8da631
--- /dev/null
+++ b/front_minimal/src/hooks/use-page-title.js
@@ -0,0 +1,59 @@
+import { useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+
+const SITE = 'Училл';
+
+const TITLES = {
+ '/dashboard': SITE,
+ '/dashboard/schedule': `Расписание | ${SITE}`,
+ '/dashboard/homework': `Домашние задания | ${SITE}`,
+ '/dashboard/materials': `Материалы | ${SITE}`,
+ '/dashboard/students': `Ученики | ${SITE}`,
+ '/dashboard/notifications': `Уведомления | ${SITE}`,
+ '/dashboard/board': `Доска | ${SITE}`,
+ '/dashboard/chat-platform': `Чат | ${SITE}`,
+ '/dashboard/analytics': `Аналитика | ${SITE}`,
+ '/dashboard/feedback': `Обратная связь | ${SITE}`,
+ '/dashboard/profile': `Профиль | ${SITE}`,
+ '/dashboard/referrals': `Рефералы | ${SITE}`,
+ '/dashboard/payment-platform': `Оплата | ${SITE}`,
+ '/dashboard/children': `Дети | ${SITE}`,
+ '/dashboard/children-progress': `Прогресс детей | ${SITE}`,
+ '/dashboard/my-progress': `Мой прогресс | ${SITE}`,
+ '/dashboard/groups': `Группы | ${SITE}`,
+ '/dashboard/prejoin': `Подключение к уроку | ${SITE}`,
+ '/auth/jwt/sign-in': `Вход | ${SITE}`,
+ '/auth/jwt/sign-up': `Регистрация | ${SITE}`,
+ '/auth/jwt/forgot-password': `Восстановление пароля | ${SITE}`,
+ '/auth/jwt/reset-password': `Новый пароль | ${SITE}`,
+ '/auth/jwt/verify-email': `Подтверждение email | ${SITE}`,
+ '/video-call': `Урок | ${SITE}`,
+ '/404': `Страница не найдена | ${SITE}`,
+};
+
+export function usePageTitle() {
+ const { pathname } = useLocation();
+
+ useEffect(() => {
+ // Точное совпадение
+ if (TITLES[pathname]) {
+ document.title = TITLES[pathname];
+ return;
+ }
+
+ // Динамические маршруты
+ if (pathname.match(/^\/dashboard\/students\/.+/)) {
+ document.title = `Ученик | ${SITE}`;
+ } else if (pathname.match(/^\/dashboard\/lesson\/.+/)) {
+ document.title = `Урок | ${SITE}`;
+ } else if (pathname.match(/^\/dashboard\/homework\/.+/)) {
+ document.title = `Домашнее задание | ${SITE}`;
+ } else if (pathname.match(/^\/dashboard\/groups\/.+/)) {
+ document.title = `Группа | ${SITE}`;
+ } else if (pathname.match(/^\/invite\/.+/)) {
+ document.title = `Приглашение | ${SITE}`;
+ } else {
+ document.title = SITE;
+ }
+ }, [pathname]);
+}
diff --git a/front_minimal/src/layouts/core/header-base.jsx b/front_minimal/src/layouts/core/header-base.jsx
index 2d717d4..a1cb419 100644
--- a/front_minimal/src/layouts/core/header-base.jsx
+++ b/front_minimal/src/layouts/core/header-base.jsx
@@ -98,9 +98,6 @@ export function HeaderBase({
/>
)}
- {/* -- Logo -- */}
-
-
{/* -- Divider -- */}
diff --git a/front_minimal/src/locales/localization-provider.jsx b/front_minimal/src/locales/localization-provider.jsx
index 73af1d3..fe4e025 100644
--- a/front_minimal/src/locales/localization-provider.jsx
+++ b/front_minimal/src/locales/localization-provider.jsx
@@ -8,13 +8,17 @@ import 'dayjs/locale/zh-cn';
import 'dayjs/locale/ar-sa';
import 'dayjs/locale/ru'; // Добавил русский
+import utc from 'dayjs/plugin/utc';
+import timezone from 'dayjs/plugin/timezone';
import dayjs from 'dayjs';
-
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
import { LocalizationProvider as Provider } from '@mui/x-date-pickers/LocalizationProvider';
import { useTranslate } from './use-locales';
+dayjs.extend(utc);
+dayjs.extend(timezone);
+
// ----------------------------------------------------------------------
export function LocalizationProvider({ children }) {
diff --git a/front_minimal/src/routes/sections.jsx b/front_minimal/src/routes/sections.jsx
index a0c1352..fa56c1c 100644
--- a/front_minimal/src/routes/sections.jsx
+++ b/front_minimal/src/routes/sections.jsx
@@ -1,8 +1,11 @@
import { lazy, Suspense } from 'react';
import { Navigate, useRoutes, useParams, Outlet } from 'react-router-dom';
+import { usePageTitle } from 'src/hooks/use-page-title';
+
import { AuthGuard } from 'src/auth/guard/auth-guard';
import { GuestGuard } from 'src/auth/guard/guest-guard';
+import { SubscriptionGuard } from 'src/auth/guard/subscription-guard';
import { AuthSplitLayout } from 'src/layouts/auth-split';
import { DashboardLayout } from 'src/layouts/dashboard';
@@ -132,9 +135,11 @@ function S({ children }) {
function DashboardLayoutWrapper() {
return (
-
-
-
+
+
+
+
+
);
}
@@ -167,6 +172,7 @@ function HomeworkDetailWrapper() {
// ----------------------------------------------------------------------
export function Router() {
+ usePageTitle();
return useRoutes([
// Root redirect
{ path: '/', element: },
diff --git a/front_minimal/src/sections/analytics/view/analytics-view.jsx b/front_minimal/src/sections/analytics/view/analytics-view.jsx
index 6fe9869..6c954d2 100644
--- a/front_minimal/src/sections/analytics/view/analytics-view.jsx
+++ b/front_minimal/src/sections/analytics/view/analytics-view.jsx
@@ -144,12 +144,20 @@ function IncomeTab() {
key={i}
direction="row"
justifyContent="space-between"
+ alignItems="center"
py={1}
sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
>
-
- {i + 1}. {item.lesson_title || item.target_name || 'Занятие'}
-
+
+
+ {i + 1}. {item.lesson_title || 'Занятие'}
+
+ {item.target_name && (
+
+ {item.target_name}
+
+ )}
+
{formatCurrency(item.total_income)}
diff --git a/front_minimal/src/sections/auth/jwt/jwt-sign-up-view.jsx b/front_minimal/src/sections/auth/jwt/jwt-sign-up-view.jsx
index f4f6790..2001987 100644
--- a/front_minimal/src/sections/auth/jwt/jwt-sign-up-view.jsx
+++ b/front_minimal/src/sections/auth/jwt/jwt-sign-up-view.jsx
@@ -31,6 +31,8 @@ import { signUp } from 'src/auth/context/jwt';
import { parseApiError } from 'src/utils/parse-api-error';
import { useAuthContext } from 'src/auth/hooks';
import { searchCities } from 'src/utils/profile-api';
+import { setReferrer } from 'src/utils/referrals-api';
+import { useSearchParams } from 'src/routes/hooks';
// ----------------------------------------------------------------------
@@ -122,6 +124,7 @@ function CityAutocomplete({ value, onChange, error, helperText }) {
export function JwtSignUpView() {
const { checkUserSession } = useAuthContext();
const router = useRouter();
+ const searchParams = useSearchParams();
const password = useBoolean();
const passwordConfirm = useBoolean();
@@ -129,6 +132,7 @@ export function JwtSignUpView() {
const [errorMsg, setErrorMsg] = useState('');
const [successMsg, setSuccessMsg] = useState('');
const [consent, setConsent] = useState(false);
+ const [refCode, setRefCode] = useState(() => searchParams.get('ref') || '');
const defaultValues = {
firstName: '',
@@ -168,6 +172,11 @@ export function JwtSignUpView() {
city: data.city,
});
+ // Применяем реферальный код если указан (после регистрации, до редиректа)
+ if (refCode.trim()) {
+ try { await setReferrer(refCode.trim()); } catch { /* игнорируем — не блокируем регистрацию */ }
+ }
+
if (result?.requiresVerification) {
setSuccessMsg(
`Письмо с подтверждением отправлено на ${data.email}. Пройдите по ссылке в письме для активации аккаунта.`
@@ -201,15 +210,23 @@ export function JwtSignUpView() {
if (successMsg) {
return (
- <>
- {renderHead}
- {successMsg}
-
-
- Вернуться ко входу
-
+
+
+
+ Подтвердите ваш email
+
+ Мы отправили письмо с ссылкой для активации аккаунта на адрес:
+
+ {methods.getValues('email')}
- >
+
+ Перейдите по ссылке в письме, чтобы завершить регистрацию. Если письмо не пришло —
+ проверьте папку «Спам».
+
+
+ Вернуться ко входу
+
+
);
}
@@ -275,6 +292,22 @@ export function JwtSignUpView() {
}}
/>
+ setRefCode(e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8))}
+ size="small"
+ InputLabelProps={{ shrink: true }}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ />
+
setConsent(e.target.checked)} />}
label={
diff --git a/front_minimal/src/sections/board/view/board-view.jsx b/front_minimal/src/sections/board/view/board-view.jsx
index 7054c45..6d69e7f 100644
--- a/front_minimal/src/sections/board/view/board-view.jsx
+++ b/front_minimal/src/sections/board/view/board-view.jsx
@@ -16,50 +16,12 @@ import CardContent from '@mui/material/CardContent';
import CardActionArea from '@mui/material/CardActionArea';
import CircularProgress from '@mui/material/CircularProgress';
-import { CONFIG } from 'src/config-global';
import { Iconify } from 'src/components/iconify';
import { DashboardContent } from 'src/layouts/dashboard';
import { useAuthContext } from 'src/auth/hooks';
-import { getMyBoards, getSharedBoards, getOrCreateMentorStudentBoard } from 'src/utils/board-api';
+import { getMyBoards, getSharedBoards, getOrCreateMentorStudentBoard, getOrCreateGroupBoard, buildExcalidrawSrc } from 'src/utils/board-api';
import { getMentorStudents } from 'src/utils/dashboard-api';
-
-// ----------------------------------------------------------------------
-
-function buildExcalidrawSrc(boardId, user) {
- const token =
- typeof window !== 'undefined'
- ? localStorage.getItem('jwt_access_token') || localStorage.getItem('access_token') || ''
- : '';
-
- const serverUrl = CONFIG.site.serverUrl || '';
- const apiUrl = serverUrl.replace(/\/api\/?$/, '') || '';
- const isMentor = user?.role === 'mentor';
-
- const excalidrawUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
- const excalidrawPath = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
- const excalidrawPort = process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001';
- const yjsPort = process.env.NEXT_PUBLIC_YJS_PORT || '1236';
-
- if (excalidrawUrl && excalidrawUrl.startsWith('http')) {
- const url = new URL(`${excalidrawUrl}/`);
- url.searchParams.set('boardId', boardId);
- url.searchParams.set('apiUrl', apiUrl);
- url.searchParams.set('yjsPort', yjsPort);
- if (token) url.searchParams.set('token', token);
- if (isMentor) url.searchParams.set('isMentor', '1');
- return url.toString();
- }
-
- const origin = excalidrawPath
- ? (typeof window !== 'undefined' ? window.location.origin : '')
- : `${typeof window !== 'undefined' ? window.location.protocol : 'https:'}//${typeof window !== 'undefined' ? window.location.hostname : ''}:${excalidrawPort}`;
- const pathname = excalidrawPath ? `/${excalidrawPath.replace(/^\//, '')}/` : '/';
- const params = new URLSearchParams({ boardId, apiUrl });
- params.set('yjsPort', yjsPort);
- if (token) params.set('token', token);
- if (isMentor) params.set('isMentor', '1');
- return `${origin}${pathname}?${params.toString()}`;
-}
+import { getGroups } from 'src/utils/groups-api';
// ----------------------------------------------------------------------
@@ -128,6 +90,7 @@ function getUserInitials(u) {
function BoardCard({ board, currentUser, onClick }) {
const isMentor = currentUser?.role === 'mentor';
+ const isGroup = board.access_type === 'group';
const otherPerson = isMentor ? board.student : board.mentor;
const otherName = getUserName(otherPerson);
const otherInitials = getUserInitials(otherPerson);
@@ -138,30 +101,23 @@ function BoardCard({ board, currentUser, onClick }) {
return (
- {/* Preview area */}
-
+
{board.elements_count > 0 && (
-
+
{board.elements_count} элем.
@@ -174,14 +130,23 @@ function BoardCard({ board, currentUser, onClick }) {
{board.title || 'Без названия'}
-
-
- {otherInitials}
-
-
- {isMentor ? 'Ученик: ' : 'Ментор: '}{otherName}
-
-
+ {isGroup ? (
+
+
+
+ Групповая доска
+
+
+ ) : (
+
+
+ {otherInitials}
+
+
+ {isMentor ? 'Ученик: ' : 'Ментор: '}{otherName}
+
+
+ )}
{lastEdited && (
@@ -203,6 +168,7 @@ function BoardListView({ onOpen }) {
const [loading, setLoading] = useState(true);
const [creating, setCreating] = useState(false);
const [students, setStudents] = useState([]);
+ const [groups, setGroups] = useState([]);
const load = useCallback(async () => {
setLoading(true);
@@ -220,7 +186,7 @@ function BoardListView({ onOpen }) {
useEffect(() => { load(); }, [load]);
- // Ментор может создать доску с учеником
+ // Ментор: загружаем учеников и группы
useEffect(() => {
if (user?.role !== 'mentor') return;
getMentorStudents()
@@ -229,6 +195,7 @@ function BoardListView({ onOpen }) {
setStudents(list);
})
.catch(() => {});
+ getGroups().then(setGroups).catch(() => {});
}, [user?.role]);
const handleCreateWithStudent = async (student) => {
@@ -244,6 +211,19 @@ function BoardListView({ onOpen }) {
}
};
+ const handleOpenGroupBoard = async (group) => {
+ setCreating(true);
+ try {
+ const board = await getOrCreateGroupBoard(group.id);
+ await load();
+ onOpen(board.board_id);
+ } catch (e) {
+ console.error(e);
+ } finally {
+ setCreating(false);
+ }
+ };
+
const excalidrawAvailable = !!(
process.env.NEXT_PUBLIC_EXCALIDRAW_URL ||
process.env.NEXT_PUBLIC_EXCALIDRAW_PATH ||
@@ -274,9 +254,25 @@ function BoardListView({ onOpen }) {
Доски
- {user?.role === 'mentor' && students.length > 0 && (
+ {user?.role === 'mentor' && (
- {students.slice(0, 6).map((s) => {
+ {/* Групповые доски */}
+ {groups.slice(0, 4).map((g) => (
+
+ }
+ onClick={() => handleOpenGroupBoard(g)}
+ disabled={creating}
+ >
+ {g.name}
+
+
+ ))}
+ {/* Индивидуальные доски */}
+ {students.slice(0, 5).map((s) => {
const name = getUserName(s.user || s);
const initials = getUserInitials(s.user || s);
return (
diff --git a/front_minimal/src/sections/calendar/calendar-form.jsx b/front_minimal/src/sections/calendar/calendar-form.jsx
index 6c811f6..c22bbfe 100644
--- a/front_minimal/src/sections/calendar/calendar-form.jsx
+++ b/front_minimal/src/sections/calendar/calendar-form.jsx
@@ -1,15 +1,13 @@
+// eslint-disable-next-line perfectionist/sort-imports
import 'dayjs/locale/ru';
import dayjs from 'dayjs';
import { z as zod } from 'zod';
import { useForm, Controller } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
-import { useMemo, useState, useCallback } from 'react';
-
-// ----------------------------------------------------------------------
-
+import { useMemo, useState, useCallback, useEffect } from 'react';
import { useRouter } from 'src/routes/hooks';
-
import Box from '@mui/material/Box';
+import Alert from '@mui/material/Alert';
import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
@@ -23,14 +21,15 @@ import DialogTitle from '@mui/material/DialogTitle';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import InputAdornment from '@mui/material/InputAdornment';
+import Typography from '@mui/material/Typography';
+import DialogContentText from '@mui/material/DialogContentText';
import FormControlLabel from '@mui/material/FormControlLabel';
-
+import ToggleButton from '@mui/material/ToggleButton';
+import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import { paths } from 'src/routes/paths';
-
import { createLiveKitRoom } from 'src/utils/livekit-api';
-
-import { useGetStudents, useGetSubjects } from 'src/actions/calendar';
-
+import { getLesson } from 'src/utils/dashboard-api';
+import { useGetStudents, useGetSubjects, useGetGroups } from 'src/actions/calendar';
import { Iconify } from 'src/components/iconify';
import { Form, Field } from 'src/components/hook-form';
@@ -56,34 +55,50 @@ export function CalendarForm({
onCreateEvent,
onUpdateEvent,
onDeleteEvent,
+ onCompleteLesson,
}) {
const router = useRouter();
const { students } = useGetStudents();
const { subjects } = useGetSubjects();
+ const { groups } = useGetGroups();
const [joiningVideo, setJoiningVideo] = useState(false);
+ const [fullLesson, setFullLesson] = useState(null);
+ const [confirmDelete, setConfirmDelete] = useState(false);
+ const [submitError, setSubmitError] = useState(null);
- const EventSchema = zod.object({
- client: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите ученика'),
- subject: zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите предмет'),
+ // individual | group
+ const [lessonType, setLessonType] = useState('individual');
+
+ const isGroup = lessonType === 'group';
+ const isEditing = !!currentEvent?.id;
+
+ // Dynamic schema based on lesson type
+ const EventSchema = useMemo(() => zod.object({
+ client: isGroup
+ ? zod.union([zod.string(), zod.number()]).optional()
+ : zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите ученика'),
+ group: isGroup
+ ? zod.union([zod.string(), zod.number()]).refine((val) => !!val, 'Выберите группу')
+ : zod.union([zod.string(), zod.number()]).optional(),
+ subject: zod.string().min(1, 'Введите название предмета'),
description: zod.string().max(5000).optional(),
start_time: zod.any().refine((val) => val !== null, 'Выберите начало занятия'),
duration: zod.number().min(1, 'Выберите длительность'),
price: zod.coerce.number().min(0, 'Цена не может быть отрицательной'),
is_recurring: zod.boolean().optional(),
- });
+ }), [isGroup]);
- const defaultValues = useMemo(() => {
- const start = currentEvent?.start || range?.start || new Date();
- return {
- client: currentEvent?.extendedProps?.client?.id || '',
- subject: currentEvent?.extendedProps?.mentor_subject?.id || '',
- description: currentEvent?.description || '',
- start_time: dayjs(start),
- duration: 60,
- price: 1500,
- is_recurring: false,
- };
- }, [currentEvent, range]);
+ const defaultValues = useMemo(() => ({
+ client: '',
+ group: '',
+ subject: '',
+ subjectText: '',
+ description: '',
+ start_time: dayjs(range?.start || new Date()),
+ duration: 60,
+ price: 1500,
+ is_recurring: false,
+ }), [range]);
const methods = useForm({
resolver: zodResolver(EventSchema),
@@ -98,36 +113,141 @@ export function CalendarForm({
formState: { isSubmitting },
} = methods;
+ // При открытии формы
+ useEffect(() => {
+ if (!open) { setFullLesson(null); setSubmitError(null); return; }
+
+ if (!currentEvent?.id) {
+ setLessonType('individual');
+ reset({
+ client: '',
+ group: '',
+ subject: '',
+ description: '',
+ start_time: dayjs(range?.start || new Date()),
+ duration: 60,
+ price: 1500,
+ is_recurring: false,
+ });
+ return;
+ }
+
+ // Редактирование — грузим полные данные
+ getLesson(currentEvent.id)
+ .then((data) => {
+ const lesson = data?.data ?? data;
+ setFullLesson(lesson);
+
+ // Определяем тип занятия
+ const hasGroup = !!lesson.group;
+ setLessonType(hasGroup ? 'group' : 'individual');
+
+ const clientId = lesson.client?.id ?? (typeof lesson.client === 'number' ? lesson.client : '') ?? '';
+ const groupId = lesson.group?.id ?? (typeof lesson.group === 'number' ? lesson.group : '') ?? '';
+
+ const subjectName = lesson.subject_name || lesson.subject || '';
+
+ let duration = lesson.duration ?? 60;
+ if (!lesson.duration && lesson.start_time && lesson.end_time) {
+ const diff = (new Date(lesson.end_time) - new Date(lesson.start_time)) / 60000;
+ if (diff > 0) duration = Math.round(diff);
+ }
+
+ reset({
+ client: clientId,
+ group: groupId,
+ subject: subjectName,
+ description: lesson.description || '',
+ start_time: dayjs(lesson.start_time || currentEvent.start),
+ duration,
+ price: lesson.price ?? 1500,
+ is_recurring: lesson.is_recurring ?? false,
+ });
+ })
+ .catch(() => {
+ const ep = currentEvent.extendedProps || {};
+ const hasGroup = !!ep.group;
+ setLessonType(hasGroup ? 'group' : 'individual');
+ reset({
+ client: typeof ep.client === 'number' ? ep.client : '',
+ group: ep.group?.id ?? '',
+ subject: '',
+ description: ep.description || '',
+ start_time: dayjs(currentEvent.start),
+ duration: 60,
+ price: 1500,
+ is_recurring: false,
+ });
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, currentEvent?.id, subjects.length]);
+
+ // При смене типа сбрасываем ошибки (не триггерим валидацию до сабмита)
+ useEffect(() => {
+ methods.clearErrors();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [lessonType]);
+
const values = watch();
+ const canJoinLesson = (() => {
+ if (!currentEvent?.id) return false;
+ if (currentEvent?.extendedProps?.status === 'completed') return false;
+ const now = new Date();
+ const startTime = currentEvent?.start ? new Date(currentEvent.start) : null;
+ const endTime = currentEvent?.end ? new Date(currentEvent.end) : null;
+ const started = startTime && startTime <= now;
+ const endedTooLong = endTime && (now - endTime) / 60000 > 15;
+ return started && !endedTooLong;
+ })();
+
+ const canComplete = (() => {
+ if (!currentEvent?.id) return false;
+ const status = String(currentEvent?.extendedProps?.status || '').toLowerCase();
+ if (status === 'completed' || status === 'cancelled') return false;
+ const now = new Date();
+ const startTime = currentEvent?.start ? new Date(currentEvent.start) : null;
+ return startTime && startTime <= now;
+ })();
+
const onSubmit = handleSubmit(async (data) => {
+ setSubmitError(null);
try {
const startTime = dayjs(data.start_time);
const endTime = startTime.add(data.duration, 'minute');
- // Формируем динамический title: Предмет - Преподаватель - Ученик #ID
- const selectedSubject = subjects.find(s => s.id === data.subject);
- const selectedStudent = students.find(s => s.id === data.client);
-
- const subjectName = selectedSubject?.name || selectedSubject?.subject_name || 'Урок';
-
- const studentFullName = selectedStudent?.user ? `${selectedStudent.user.last_name} ${selectedStudent.user.first_name}` : (selectedStudent?.full_name || 'Ученик');
-
- const mentorFullName = currentEvent?.extendedProps?.mentor ? `${currentEvent.extendedProps.mentor.last_name} ${currentEvent.extendedProps.mentor.first_name}` : 'Ментор';
-
- const lessonNumber = currentEvent?.id ? ` №${currentEvent.id}` : '';
-
- const displayTitle = `${subjectName} ${mentorFullName} - ${studentFullName}${lessonNumber}`.trim();
+ const subjectName = data.subject || 'Урок';
+
+ let displayTitle;
+ if (isGroup) {
+ const selectedGroup = groups.find(g => g.id === data.group || g.id === Number(data.group));
+ const groupName = selectedGroup?.name || `Группа ${data.group}`;
+ const lessonNumber = currentEvent?.id ? ` №${currentEvent.id}` : '';
+ displayTitle = `${subjectName} — ${groupName}${lessonNumber}`.trim();
+ } else {
+ const selectedStudent = students.find(s => s.id === data.client);
+ const studentFullName = selectedStudent?.user
+ ? `${selectedStudent.user.last_name} ${selectedStudent.user.first_name}`
+ : (selectedStudent?.full_name || 'Ученик');
+ const lessonNumber = currentEvent?.id ? ` №${currentEvent.id}` : '';
+ displayTitle = `${subjectName} — ${studentFullName}${lessonNumber}`.trim();
+ }
+
+ // Находим id предмета по названию если есть
+ const foundSubject = subjects.find(
+ (s) => (s.name || s.subject_name || '').toLowerCase() === subjectName.toLowerCase()
+ );
const payload = {
title: displayTitle,
- client: data.client,
- subject: data.subject,
+ subject: foundSubject?.id ?? null,
+ subject_name: subjectName,
description: data.description,
start_time: startTime.toISOString(),
duration: data.duration,
price: data.price,
is_recurring: data.is_recurring,
+ ...(isGroup ? { group: data.group } : { client: data.client }),
};
if (currentEvent?.id) {
@@ -138,7 +258,18 @@ export function CalendarForm({
onClose();
reset();
} catch (error) {
- console.error(error);
+ const errData = error?.response?.data;
+ const details = errData?.error?.details;
+ const msg =
+ errData?.error?.message ||
+ details?.start_time?.[0] ||
+ details?.start_time ||
+ details?.detail ||
+ details?.non_field_errors?.[0] ||
+ errData?.detail ||
+ 'Не удалось сохранить занятие';
+ const raw = Array.isArray(msg) ? msg[0] : String(msg);
+ setSubmitError(raw.replace(/^\w+:\s*/, ''));
}
});
@@ -148,7 +279,7 @@ export function CalendarForm({
try {
const room = await createLiveKitRoom(currentEvent.id);
const token = room.access_token || room.token;
- router.push(`${paths.videoCall}?token=${encodeURIComponent(token)}&lesson_id=${currentEvent.id}`);
+ router.push(`${paths.dashboard.prejoin}?token=${encodeURIComponent(token)}&lesson_id=${currentEvent.id}`);
onClose();
} catch (e) {
console.error('LiveKit join error', e);
@@ -176,64 +307,97 @@ export function CalendarForm({
-
-
-
- {students.map((student) => {
- const studentName = student.user?.full_name ||
- student.full_name ||
- (student.user?.first_name ? `${student.user.first_name} ${student.user.last_name || ''}` : '') ||
- student.email ||
- `ID: ${student.id}`;
-
- const studentAvatar = student.user?.avatar || student.avatar;
- const letter = studentName.charAt(0).toUpperCase();
- return (
-
+ ) : (
+
+ Выберите ученика
+ {students.map((student) => {
+ const studentName = student.user?.full_name ||
+ student.full_name ||
+ (student.user?.first_name ? `${student.user.first_name} ${student.user.last_name || ''}` : '') ||
+ student.email ||
+ `ID: ${student.id}`;
+ const studentAvatar = student.user?.avatar || student.avatar;
+ const letter = studentName.charAt(0).toUpperCase();
+ return (
+
+
+
+ {letter}
+
+ {studentName}
+
+
+ );
+ })}
+
+ )}
Выберите предмет
{subjects.map((sub) => (
-
+
{sub.name || sub.subject_name || sub.title || `Предмет ID: ${sub.id}`}
))}
-
-
@@ -268,24 +432,38 @@ export function CalendarForm({
/>
- }
- />
- }
- label="Постоянное занятие (повторяется еженедельно)"
- sx={{ ml: 0 }}
- />
+ {currentEvent?.id ? (
+ values.is_recurring && (
+
+ Постоянное занятие (повторяется еженедельно)
+
+ )
+ ) : (
+ }
+ />
+ }
+ label="Постоянное занятие (повторяется еженедельно)"
+ sx={{ ml: 0 }}
+ />
+ )}
+ {submitError && (
+ setSubmitError(null)} sx={{ mx: 3, mb: 1 }}>
+ {submitError}
+
+ )}
+
{currentEvent?.id && (
-
+ setConfirmDelete(true)} color="error">
@@ -293,15 +471,28 @@ export function CalendarForm({
- {currentEvent?.id && currentEvent?.extendedProps?.status !== 'completed' && (
+ {canComplete && onCompleteLesson && (
+
+
+
+ )}
+
+ {canJoinLesson && (
}
>
- Войти
+ Подключиться
)}
@@ -310,10 +501,27 @@ export function CalendarForm({
- {currentEvent?.id ? 'Сохранить изменения' : 'Создать'}
+ {currentEvent?.id ? 'Сохранить' : 'Создать'}
+
+
);
}
diff --git a/front_minimal/src/sections/calendar/view/calendar-view.jsx b/front_minimal/src/sections/calendar/view/calendar-view.jsx
index 4d9a89b..6deac2f 100644
--- a/front_minimal/src/sections/calendar/view/calendar-view.jsx
+++ b/front_minimal/src/sections/calendar/view/calendar-view.jsx
@@ -343,9 +343,14 @@ function CompleteLessonDialog({ lessonId: propLessonId, open: propOpen, onClose:
// ----------------------------------------------------------------------
+function isValidTimezone(tz) {
+ if (!tz) return false;
+ try { Intl.DateTimeFormat(undefined, { timeZone: tz }); return true; } catch { return false; }
+}
+
function fTime(str, timezone) {
if (!str) return '';
- const opts = { hour: '2-digit', minute: '2-digit', ...(timezone ? { timeZone: timezone } : {}) };
+ const opts = { hour: '2-digit', minute: '2-digit', ...(isValidTimezone(timezone) ? { timeZone: timezone } : {}) };
return new Date(str).toLocaleTimeString('ru-RU', opts);
}
@@ -396,10 +401,12 @@ function UpcomingLessonsBar({ events, isMentor, timezone }) {
const canJoin = diffMin < 11 && diffMin > -90;
const isJoining = joiningId === ev.id;
- // Mentor sees student name; student sees mentor name
- const personName = isMentor
- ? (ep.student || ep.client_name || '')
- : (ep.mentor_name || ep.mentor || '');
+ // Групповое занятие — показываем название группы, индивидуальное — имя ученика/ментора
+ const personName = ep.group_name
+ ? ep.group_name
+ : isMentor
+ ? (ep.student || ep.client_name || '')
+ : (ep.mentor_name || ep.mentor || '');
const subject = ep.subject_name || ep.subject || ev.title || 'Занятие';
const initial = (personName[0] || subject[0] || 'З').toUpperCase();
@@ -604,7 +611,7 @@ export function CalendarView() {
}}
plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, timelinePlugin]}
locale={ruLocale}
- timeZone={user?.timezone || 'local'}
+ timeZone={isValidTimezone(user?.timezone) ? user.timezone : 'local'}
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
/>
diff --git a/front_minimal/src/sections/chat/view/chat-platform-view.jsx b/front_minimal/src/sections/chat/view/chat-platform-view.jsx
index c425c60..c99027b 100644
--- a/front_minimal/src/sections/chat/view/chat-platform-view.jsx
+++ b/front_minimal/src/sections/chat/view/chat-platform-view.jsx
@@ -1,867 +1,587 @@
-'use client';
-
-import { useRef, useState, useEffect, useCallback } from 'react';
-
-import Box from '@mui/material/Box';
-import List from '@mui/material/List';
-import Stack from '@mui/material/Stack';
-import Alert from '@mui/material/Alert';
-import Badge from '@mui/material/Badge';
-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 TextField from '@mui/material/TextField';
-import Typography from '@mui/material/Typography';
-import IconButton from '@mui/material/IconButton';
-import DialogTitle from '@mui/material/DialogTitle';
-import ListItemText from '@mui/material/ListItemText';
-import DialogContent from '@mui/material/DialogContent';
-import DialogActions from '@mui/material/DialogActions';
-import InputAdornment from '@mui/material/InputAdornment';
-import ListItemButton from '@mui/material/ListItemButton';
-import CircularProgress from '@mui/material/CircularProgress';
-
-import { paths } from 'src/routes/paths';
-
-import { useChatWebSocket } from 'src/hooks/use-chat-websocket';
-
-import {
- createChat,
- getMessages,
- searchUsers,
- sendMessage,
- getConversations,
- markMessagesAsRead,
- getChatMessagesByUuid,
- normalizeChat,
-} from 'src/utils/chat-api';
-
-import { getStudents, getMyMentors } from 'src/utils/students-api';
-
-import { DashboardContent } from 'src/layouts/dashboard';
-
-import { Iconify } from 'src/components/iconify';
-import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
-
-import { useAuthContext } from 'src/auth/hooks';
-
-// ----------------------------------------------------------------------
-
-function formatTime(ts) {
- try {
- if (!ts) return '';
- const d = new Date(ts);
- if (Number.isNaN(d.getTime())) return '';
- return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
- } catch {
- return '';
- }
-}
-
-function dateKey(ts) {
- if (!ts) return '';
- const d = new Date(ts);
- if (Number.isNaN(d.getTime())) return '';
- return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
-}
-
-function formatDayHeader(ts) {
- if (!ts) return '';
- const d = new Date(ts);
- if (Number.isNaN(d.getTime())) return '';
- const now = new Date();
- const todayKey = dateKey(now.toISOString());
- const yKey = dateKey(new Date(now.getTime() - 86400000).toISOString());
- const k = dateKey(ts);
- if (k === todayKey) return 'Сегодня';
- if (k === yKey) return 'Вчера';
- return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`;
-}
-
-function getInitials(name) {
- return (name || 'Ч')
- .trim()
- .split(/\s+/)
- .slice(0, 2)
- .map((p) => p[0])
- .join('')
- .toUpperCase();
-}
-
-function stripHtml(s) {
- if (typeof s !== 'string') return '';
- return s.replace(/<[^>]*>/g, '').trim();
-}
-
-// ----------------------------------------------------------------------
-
-function NewChatDialog({ open, onClose, onCreated }) {
- const [query, setQuery] = useState('');
- const [results, setResults] = useState([]);
- const [searching, setSearching] = useState(false);
- const [creating, setCreating] = useState(false);
- const [error, setError] = useState(null);
-
- const handleSearch = useCallback(async (q) => {
- if (!q.trim()) {
- setResults([]);
- return;
- }
- try {
- setSearching(true);
- const users = await searchUsers(q.trim());
- setResults(users);
- } catch {
- setResults([]);
- } finally {
- setSearching(false);
- }
- }, []);
-
- useEffect(() => {
- const timer = setTimeout(() => handleSearch(query), 400);
- return () => clearTimeout(timer);
- }, [query, handleSearch]);
-
- const handleCreate = async (user) => {
- try {
- setCreating(true);
- setError(null);
- const rawChat = await createChat(user.id);
- const enriched = {
- ...rawChat,
- other_participant: rawChat?.other_participant ?? {
- id: user.id,
- first_name: user.first_name,
- last_name: user.last_name,
- avatar_url: user.avatar_url || user.avatar || null,
- },
- };
- onCreated(enriched);
- onClose();
- } catch (e) {
- setError(e?.response?.data?.detail || e?.message || 'Ошибка создания чата');
- } finally {
- setCreating(false);
- }
- };
-
- const handleClose = () => {
- setQuery('');
- setResults([]);
- setError(null);
- onClose();
- };
-
- return (
-
- );
-}
-
-// ----------------------------------------------------------------------
-
-function ChatList({ chats, selectedUuid, onSelect, onNew, loading, contacts, onStartChat, startingId }) {
- const [q, setQ] = useState('');
-
- // ID пользователей у которых уже есть чат
- const existingParticipantIds = new Set(chats.map((c) => c.participant_id).filter(Boolean));
-
- const filteredChats = chats.filter((c) => {
- if (!q.trim()) return true;
- const qq = q.toLowerCase();
- return (
- (c.participant_name || '').toLowerCase().includes(qq) ||
- (c.last_message || '').toLowerCase().includes(qq)
- );
- });
-
- const filteredContacts = contacts.filter((c) => {
- if (existingParticipantIds.has(c.id)) return false;
- if (!q.trim()) return true;
- const qq = q.toLowerCase();
- const name = `${c.first_name || ''} ${c.last_name || ''}`.toLowerCase();
- return name.includes(qq) || (c.email || '').toLowerCase().includes(qq);
- });
-
- return (
-
-
- setQ(e.target.value)}
- placeholder="Поиск"
- size="small"
- fullWidth
- InputProps={{
- startAdornment: (
-
-
-
- ),
- }}
- />
-
-
-
-
-
-
- {loading ? (
-
-
-
- ) : (
- <>
- {/* Существующие чаты */}
- {filteredChats.length > 0 && (
-
- {filteredChats.map((chat) => {
- const selected = !!selectedUuid && chat.uuid === selectedUuid;
- return (
- onSelect(chat)}
- sx={{ py: 1.25, px: 1.5 }}
- >
-
- {getInitials(chat.participant_name)}
-
-
-
- {chat.participant_name || 'Чат'}
-
- {!!chat.unread_count && (
-
- )}
-
- }
- secondary={
-
- {stripHtml(chat.last_message || '')}
-
- }
- primaryTypographyProps={{ component: 'div' }}
- secondaryTypographyProps={{ component: 'div' }}
- />
-
- );
- })}
-
- )}
-
- {/* Контакты без чата */}
- {filteredContacts.length > 0 && (
- <>
- 0 ? 1.5 : 1, pb: 0.5, display: 'block', color: 'text.disabled' }}
- >
- Контакты
-
-
- {filteredContacts.map((contact) => {
- const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim() || contact.email || '—';
- const isStarting = startingId === contact.id;
- return (
- onStartChat(contact)}
- disabled={isStarting}
- sx={{ py: 1.25, px: 1.5, opacity: isStarting ? 0.6 : 1 }}
- >
-
- {getInitials(name)}
-
-
- {name}
-
- }
- secondary={
-
- {isStarting ? 'Создание чата...' : 'Начать диалог'}
-
- }
- primaryTypographyProps={{ component: 'div' }}
- secondaryTypographyProps={{ component: 'div' }}
- />
- {isStarting ? (
-
- ) : (
-
- )}
-
- );
- })}
-
- >
- )}
-
- {filteredChats.length === 0 && filteredContacts.length === 0 && (
-
-
-
- {q ? 'Ничего не найдено' : 'Нет чатов и контактов'}
-
-
- )}
- >
- )}
-
-
- );
-}
-
-// ----------------------------------------------------------------------
-
-function ChatWindow({ chat, currentUserId, onBack }) {
- const [messages, setMessages] = useState([]);
- const [loading, setLoading] = useState(false);
- const [loadingMore, setLoadingMore] = useState(false);
- const [page, setPage] = useState(1);
- const [hasMore, setHasMore] = useState(false);
- const [text, setText] = useState('');
- const [sending, setSending] = useState(false);
- const [sendError, setSendError] = useState(null);
- const listRef = useRef(null);
- const markedRef = useRef(new Set());
- const lastSentRef = useRef(null);
- const lastWheelUpRef = useRef(0);
-
- const chatUuid = chat?.uuid || null;
-
- useChatWebSocket({
- chatUuid,
- enabled: !!chatUuid,
- onMessage: (m) => {
- const chatId = chat?.id != null ? Number(chat.id) : null;
- const msgChatId = m.chat != null ? Number(m.chat) : null;
- if (chatId == null || msgChatId !== chatId) return;
- const mid = m.id;
- const muuid = m.uuid;
- const sent = lastSentRef.current;
- if (
- sent &&
- (String(mid) === String(sent.id) ||
- (muuid != null && sent.uuid != null && String(muuid) === String(sent.uuid)))
- ) {
- lastSentRef.current = null;
- return;
- }
- setMessages((prev) => {
- const isDuplicate = prev.some((x) => {
- const sameId = mid != null && x.id != null && String(x.id) === String(mid);
- const sameUuid = muuid != null && x.uuid != null && String(x.uuid) === String(muuid);
- return sameId || sameUuid;
- });
- if (isDuplicate) return prev;
- return [...prev, m];
- });
- },
- });
-
- useEffect(() => {
- if (!chat) return undefined;
- setLoading(true);
- setPage(1);
- setHasMore(false);
- markedRef.current = new Set();
- lastSentRef.current = null;
-
- const fetchMessages = async () => {
- try {
- const PAGE_SIZE = 30;
- const resp = chatUuid
- ? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: PAGE_SIZE })
- : await getMessages(chat.id, { page: 1, page_size: PAGE_SIZE });
- const sorted = (resp.results || []).slice().sort((a, b) => {
- const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
- const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
- return ta - tb;
- });
- setMessages(sorted);
- setHasMore(!!resp.next || (resp.count ?? 0) > sorted.length);
- requestAnimationFrame(() => {
- const el = listRef.current;
- if (el) el.scrollTop = el.scrollHeight;
- });
- } finally {
- setLoading(false);
- }
- };
- fetchMessages();
- return undefined;
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [chat?.id, chatUuid]);
-
- useEffect(() => {
- if (!chatUuid || !listRef.current || messages.length === 0) return undefined;
- const container = listRef.current;
- const observer = new IntersectionObserver(
- (entries) => {
- const toMark = [];
- entries.forEach((e) => {
- if (!e.isIntersecting) return;
- const uuid = e.target.getAttribute('data-message-uuid');
- const isMine = e.target.getAttribute('data-is-mine') === 'true';
- if (uuid && !isMine && !markedRef.current.has(uuid)) {
- toMark.push(uuid);
- markedRef.current.add(uuid);
- }
- });
- if (toMark.length > 0) {
- markMessagesAsRead(chatUuid, toMark).catch(() => {});
- }
- },
- { root: container, threshold: 0.5 }
- );
- container.querySelectorAll('[data-message-uuid]').forEach((n) => observer.observe(n));
- return () => observer.disconnect();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [chatUuid, messages]);
-
- const loadOlder = useCallback(async () => {
- if (!chat || loading || loadingMore || !hasMore) return;
- const container = listRef.current;
- if (!container) return;
- setLoadingMore(true);
- const prevScrollHeight = container.scrollHeight;
- const prevScrollTop = container.scrollTop;
- try {
- const nextPage = page + 1;
- const PAGE_SIZE = 30;
- const resp = chatUuid
- ? await getChatMessagesByUuid(chatUuid, { page: nextPage, page_size: PAGE_SIZE })
- : await getMessages(chat.id, { page: nextPage, page_size: PAGE_SIZE });
- const batch = (resp.results || []).slice().sort((a, b) => {
- const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
- const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
- return ta - tb;
- });
- setMessages((prev) => {
- const keys = new Set(prev.map((m) => m.uuid || m.id));
- const toAdd = batch.filter((m) => !keys.has(m.uuid || m.id));
- return [...toAdd, ...prev].sort((a, b) => {
- const ta = a.created_at ? new Date(a.created_at).getTime() : 0;
- const tb = b.created_at ? new Date(b.created_at).getTime() : 0;
- return ta - tb;
- });
- });
- setPage(nextPage);
- setHasMore(!!resp.next);
- } finally {
- setTimeout(() => {
- const c = listRef.current;
- if (!c) return;
- c.scrollTop = prevScrollTop + (c.scrollHeight - prevScrollHeight);
- }, 0);
- setLoadingMore(false);
- }
- }, [chat, chatUuid, hasMore, loading, loadingMore, page]);
-
- const handleSend = async () => {
- if (!chat || !text.trim() || sending) return;
- const content = text.trim();
- setText('');
- setSendError(null);
- setSending(true);
- try {
- const msg = await sendMessage(chat.id, content);
- lastSentRef.current = { id: msg.id, uuid: msg.uuid };
- const safeMsg = { ...msg, created_at: msg.created_at || new Date().toISOString() };
- setMessages((prev) => [...prev, safeMsg]);
- requestAnimationFrame(() => {
- requestAnimationFrame(() => {
- const el = listRef.current;
- if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
- });
- });
- } catch (e) {
- setText(content);
- const errMsg = e?.response?.data?.error?.message || e?.response?.data?.detail || e?.message || 'Ошибка отправки';
- setSendError(errMsg);
- } finally {
- setSending(false);
- }
- };
-
- if (!chat) {
- return (
-
- Выберите чат из списка
-
- );
- }
-
- const seen = new Set();
- const uniqueMessages = messages.filter((m) => {
- const k = String(m.uuid ?? m.id ?? '');
- if (!k || seen.has(k)) return false;
- seen.add(k);
- return true;
- });
-
- const grouped = [];
- let prevDay = '';
- uniqueMessages.forEach((m, idx) => {
- const day = dateKey(m.created_at);
- if (day && day !== prevDay) {
- grouped.push({ type: 'day', key: `day-${day}`, label: formatDayHeader(m.created_at) });
- prevDay = day;
- }
- const senderId = m.sender_id ?? (typeof m.sender === 'number' ? m.sender : m.sender?.id ?? null);
- const isMine = !!currentUserId && senderId === currentUserId;
- const isSystem =
- m.message_type === 'system' ||
- (typeof m.sender === 'string' && m.sender.toLowerCase() === 'system') ||
- (!senderId && m.sender_name === 'System');
- grouped.push({
- type: 'msg',
- key: m.uuid || m.id || `msg-${idx}`,
- msg: m,
- isMine,
- isSystem,
- });
- });
-
- return (
-
- {/* Header */}
-
- {onBack && (
-
-
-
- )}
- {getInitials(chat.participant_name)}
-
- {chat.participant_name || 'Чат'}
- {chat.other_is_online && (
-
- Онлайн
-
- )}
-
-
-
- {/* Messages */}
- {
- if (e.deltaY < 0) lastWheelUpRef.current = Date.now();
- }}
- onScroll={(e) => {
- const el = e.currentTarget;
- if (el.scrollTop < 40 && Date.now() - lastWheelUpRef.current < 200) loadOlder();
- }}
- >
- {loadingMore && (
-
- Загрузка…
-
- )}
- {loading ? (
-
-
-
- ) : (
- grouped.map((item) => {
- if (item.type === 'day') {
- return (
-
-
- {item.label}
-
-
- );
- }
- const { msg, isMine, isSystem } = item;
- const msgUuid = msg.uuid ? String(msg.uuid) : null;
- return (
-
-
- {stripHtml(msg.content || '')}
-
-
- {formatTime(msg.created_at)}
-
-
- );
- })
- )}
-
-
- {/* Input */}
-
- {sendError && (
- setSendError(null)} sx={{ mx: 1.5, mt: 1, borderRadius: 1 }}>
- {sendError}
-
- )}
-
- setText(e.target.value)}
- placeholder="Сообщение…"
- fullWidth
- multiline
- minRows={1}
- maxRows={4}
- size="small"
- onKeyDown={(e) => {
- if (e.key === 'Enter' && !e.shiftKey) {
- e.preventDefault();
- handleSend();
- }
- }}
- />
-
- {sending ? : }
-
-
-
- );
-}
-
-// ----------------------------------------------------------------------
-
-export function ChatPlatformView() {
- const { user } = useAuthContext();
- const [chats, setChats] = useState([]);
- const [contacts, setContacts] = useState([]);
- const [loading, setLoading] = useState(true);
- const [selectedChat, setSelectedChat] = useState(null);
- const [newChatOpen, setNewChatOpen] = useState(false);
- const [startingId, setStartingId] = useState(null);
- const [error, setError] = useState(null);
- const mobileShowWindow = !!selectedChat;
-
- const load = useCallback(async () => {
- try {
- setLoading(true);
- const [chatsRes, contactsRes] = await Promise.allSettled([
- getConversations({ page_size: 100 }),
- user?.role === 'mentor'
- ? getStudents({ page_size: 100 }).then((r) => r.results.map((s) => ({ ...s.user, id: s.user?.id })).filter(Boolean))
- : getMyMentors().then((list) => (Array.isArray(list) ? list : [])),
- ]);
-
- if (chatsRes.status === 'fulfilled') setChats(chatsRes.value.results ?? []);
- if (contactsRes.status === 'fulfilled') setContacts(contactsRes.value ?? []);
- } catch (e) {
- setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
- } finally {
- setLoading(false);
- }
- }, [user?.role]);
-
- useEffect(() => {
- load();
- }, [load]);
-
- const handleChatCreated = useCallback((rawChat) => {
- const chat = normalizeChat(rawChat);
- setChats((prev) => {
- const exists = prev.some((c) => c.uuid === chat.uuid || c.id === chat.id);
- if (exists) return prev;
- return [chat, ...prev];
- });
- setSelectedChat(chat);
- }, []);
-
- const handleStartChat = useCallback(async (contact) => {
- const existing = chats.find((c) => c.participant_id === contact.id);
- if (existing) { setSelectedChat(existing); return; }
- try {
- setStartingId(contact.id);
- const rawChat = await createChat(contact.id);
- // Если бэкенд не вернул other_participant — заполняем из данных контакта,
- // чтобы normalizeChat правильно выставил participant_id и имя
- const enriched = {
- ...rawChat,
- other_participant: rawChat?.other_participant ?? {
- id: contact.id,
- first_name: contact.first_name,
- last_name: contact.last_name,
- avatar_url: contact.avatar_url || contact.avatar || null,
- },
- };
- handleChatCreated(enriched);
- } catch (e) {
- setError(e?.response?.data?.detail || e?.message || 'Ошибка создания чата');
- } finally {
- setStartingId(null);
- }
- }, [chats, handleChatCreated]);
-
- return (
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
-
-
- setNewChatOpen(true)}
- onStartChat={handleStartChat}
- startingId={startingId}
- loading={loading}
- />
-
-
-
- setSelectedChat(null)}
- />
-
-
-
- setNewChatOpen(false)}
- onCreated={handleChatCreated}
- />
-
- );
-}
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+
+import Tab from '@mui/material/Tab';
+import Box from '@mui/material/Box';
+import List from '@mui/material/List';
+import Tabs from '@mui/material/Tabs';
+import Stack from '@mui/material/Stack';
+import Alert from '@mui/material/Alert';
+import Badge from '@mui/material/Badge';
+import Avatar from '@mui/material/Avatar';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import Tooltip from '@mui/material/Tooltip';
+import TextField from '@mui/material/TextField';
+import Typography from '@mui/material/Typography';
+import IconButton from '@mui/material/IconButton';
+import DialogTitle from '@mui/material/DialogTitle';
+import ListItemText from '@mui/material/ListItemText';
+import DialogContent from '@mui/material/DialogContent';
+import DialogActions from '@mui/material/DialogActions';
+import InputAdornment from '@mui/material/InputAdornment';
+import ListItemButton from '@mui/material/ListItemButton';
+import CircularProgress from '@mui/material/CircularProgress';
+
+import { paths } from 'src/routes/paths';
+
+import { getGroups } from 'src/utils/groups-api';
+import { getStudents, getMyMentors } from 'src/utils/students-api';
+import {
+ createChat,
+ searchUsers,
+ normalizeChat,
+ createGroupChat,
+ getConversations,
+} from 'src/utils/chat-api';
+
+import { DashboardContent } from 'src/layouts/dashboard';
+
+import { Iconify } from 'src/components/iconify';
+import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
+
+import { stripHtml, ChatWindow, getChatInitials } from 'src/sections/chat/chat-window';
+
+import { useAuthContext } from 'src/auth/hooks';
+
+// ----------------------------------------------------------------------
+
+function NewChatDialog({ open, onClose, onCreated }) {
+ const [query, setQuery] = useState('');
+ const [results, setResults] = useState([]);
+ const [searching, setSearching] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState(null);
+
+ const handleSearch = useCallback(async (q) => {
+ if (!q.trim()) { setResults([]); return; }
+ try {
+ setSearching(true);
+ setResults(await searchUsers(q.trim()));
+ } catch {
+ setResults([]);
+ } finally {
+ setSearching(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const t = setTimeout(() => handleSearch(query), 400);
+ return () => clearTimeout(t);
+ }, [query, handleSearch]);
+
+ const handleCreate = async (user) => {
+ try {
+ setCreating(true);
+ setError(null);
+ const rawChat = await createChat(user.id);
+ onCreated({
+ ...rawChat,
+ other_participant: rawChat?.other_participant ?? {
+ id: user.id,
+ first_name: user.first_name,
+ last_name: user.last_name,
+ avatar_url: user.avatar_url || user.avatar || null,
+ },
+ });
+ onClose();
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка создания чата');
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleClose = () => { setQuery(''); setResults([]); setError(null); onClose(); };
+
+ return (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function ChatListItem({ chat, selected, onSelect }) {
+ return (
+ onSelect(chat)}
+ sx={{ py: 1.25, px: 1.5 }}
+ >
+
+ {getChatInitials(chat.participant_name)}
+
+
+
+ {chat.participant_name || 'Чат'}
+
+ {!!chat.unread_count && (
+
+ )}
+
+ }
+ secondary={
+
+ {stripHtml(chat.last_message || '')}
+
+ }
+ primaryTypographyProps={{ component: 'div' }}
+ secondaryTypographyProps={{ component: 'div' }}
+ />
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function ContactItem({ contact, onStart, isStarting }) {
+ const name = `${contact.first_name || ''} ${contact.last_name || ''}`.trim() || contact.email || '—';
+ return (
+ onStart(contact)} disabled={isStarting} sx={{ py: 1.25, px: 1.5, opacity: isStarting ? 0.6 : 1 }}>
+
+ {getChatInitials(name)}
+
+ {name}}
+ secondary={{isStarting ? 'Создание чата...' : 'Начать диалог'}}
+ primaryTypographyProps={{ component: 'div' }}
+ secondaryTypographyProps={{ component: 'div' }}
+ />
+ {isStarting ? : }
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function GroupItem({ group, existingChat, selectedUuid, onSelect, onCreateChat, creatingId }) {
+ const isCreating = creatingId === group.id;
+ if (existingChat) {
+ return ;
+ }
+ return (
+ onCreateChat(group)} disabled={isCreating} sx={{ py: 1.25, px: 1.5, opacity: isCreating ? 0.6 : 1 }}>
+
+ {getChatInitials(group.name)}
+
+ {group.name}}
+ secondary={{isCreating ? 'Создание чата...' : `${group.students_count ?? 0} уч.`}}
+ primaryTypographyProps={{ component: 'div' }}
+ secondaryTypographyProps={{ component: 'div' }}
+ />
+ {isCreating ? : }
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+// Вспомогательная функция: определить роль собеседника по чату
+function getOtherRole(chat) {
+ // other_role выставляется в normalizeChat из other_participant.role
+ // или пробуем по participants если доступны
+ if (chat.other_role) return chat.other_role;
+ const other = chat.other_participant;
+ if (other?.role) return other.role;
+ return null;
+}
+
+function ChatSidebar({
+ tab, onTabChange,
+ chats, selectedUuid, onSelectChat,
+ contacts, onStartChat, startingId,
+ groups, onCreateGroupChat, creatingGroupId,
+ loading, onNew, userRole,
+}) {
+ const [q, setQ] = useState('');
+
+ const groupChats = chats.filter((c) => c.chat_type === 'group');
+ const directChats = chats.filter((c) => c.chat_type !== 'group');
+
+ // Разделяем прямые чаты по роли собеседника
+ const studentChats = directChats.filter((c) => {
+ const role = getOtherRole(c);
+ return role === 'client' || role === null; // null = неопределённая роль, показываем в учениках
+ });
+ const parentChats = directChats.filter((c) => getOtherRole(c) === 'parent');
+
+ // Контакты без чата (ученики + родители)
+ const existingParticipantIds = new Set(directChats.map((c) => c.participant_id).filter(Boolean));
+ const filteredContacts = contacts.filter((c) => !existingParticipantIds.has(c.id));
+
+ // Для клиента — чаты с менторами идут в «Ученики» (Мои менторы)
+ const isClient = userRole === 'client';
+
+ const filterStr = (s) => (s || '').toLowerCase().includes(q.toLowerCase());
+
+ const renderEmpty = (msg) => (
+
+
+ {msg}
+
+ );
+
+ const renderTabContent = () => {
+ if (loading) {
+ return (
+
+
+
+ );
+ }
+
+ // Для клиента таб «ученики» = менторы (единственный таб прямых чатов)
+ if (tab === 0) {
+ const tabChats = isClient
+ ? directChats.filter((c) => filterStr(c.participant_name))
+ : studentChats.filter((c) => filterStr(c.participant_name));
+ const tabContacts = isClient
+ ? filteredContacts.filter((c) => filterStr(`${c.first_name || ''} ${c.last_name || ''} ${c.email || ''}`))
+ : filteredContacts
+ .filter((c) => {
+ const role = c.role || c.mentor_role || null;
+ return role === 'client' || role === null;
+ })
+ .filter((c) => filterStr(`${c.first_name || ''} ${c.last_name || ''} ${c.email || ''}`));
+
+ return (
+ <>
+ {tabChats.length > 0 && (
+
+ {tabChats.map((chat) => (
+
+ ))}
+
+ )}
+ {tabContacts.length > 0 && (
+ <>
+
+ Контакты
+
+
+ {tabContacts.map((c) => (
+
+ ))}
+
+ >
+ )}
+ {tabChats.length === 0 && tabContacts.length === 0 && renderEmpty(q ? 'Ничего не найдено' : 'Нет чатов')}
+ >
+ );
+ }
+
+ // Для клиента tab 1 = Группы (Родителей нет)
+ if (tab === 1 && isClient) {
+ // fall through to groups rendering below
+ } else if (tab === 1) {
+ // Родители — только для ментора
+ const tabChats = parentChats.filter((c) => filterStr(c.participant_name));
+ const tabContacts = filteredContacts
+ .filter((c) => c.role === 'parent')
+ .filter((c) => filterStr(`${c.first_name || ''} ${c.last_name || ''} ${c.email || ''}`));
+ return (
+ <>
+ {tabChats.length > 0 && (
+
+ {tabChats.map((chat) => (
+
+ ))}
+
+ )}
+ {tabContacts.length > 0 && (
+ <>
+
+ Контакты
+
+
+ {tabContacts.map((c) => (
+
+ ))}
+
+ >
+ )}
+ {tabChats.length === 0 && tabContacts.length === 0 && renderEmpty(q ? 'Ничего не найдено' : 'Нет чатов с родителями')}
+ >
+ );
+ }
+
+ // tab === 2 (ментор) или tab === 1 (клиент) → Группы
+ const filteredGroups = groups.filter((g) => filterStr(g.name));
+ const filteredGroupChats = groupChats.filter((c) => filterStr(c.participant_name));
+
+ // Для клиента — только групповые чаты (без кнопки создания)
+ if (isClient) {
+ return (
+ <>
+ {filteredGroupChats.length > 0 ? (
+
+ {filteredGroupChats.map((chat) => (
+
+ ))}
+
+ ) : renderEmpty(q ? 'Ничего не найдено' : 'Нет групповых чатов')}
+ >
+ );
+ }
+
+ // Для ментора — группы (с чатом или без)
+ const groupChatByGroupId = {};
+ groupChats.forEach((c) => { if (c.group) groupChatByGroupId[c.group] = c; });
+
+ return (
+ <>
+ {filteredGroups.length > 0 ? (
+
+ {filteredGroups.map((g) => (
+
+ ))}
+
+ ) : renderEmpty(q ? 'Ничего не найдено' : 'Нет групп')}
+ >
+ );
+ };
+
+ const mentorTabs = ['Ученики', 'Родители', 'Группы'];
+ const clientTabs = ['Менторы', 'Группы'];
+
+ const tabs = userRole === 'mentor' ? mentorTabs : clientTabs;
+
+ return (
+
+
+ setQ(e.target.value)}
+ placeholder="Поиск"
+ size="small"
+ fullWidth
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ />
+ {userRole === 'mentor' && (
+
+
+
+
+
+ )}
+
+
+ onTabChange(v)}
+ variant="fullWidth"
+ sx={{ borderBottom: '1px solid', borderColor: 'divider', minHeight: 40, '& .MuiTab-root': { minHeight: 40, fontSize: 12, py: 0 } }}
+ >
+ {tabs.map((label) => (
+
+ ))}
+
+
+
+ {renderTabContent()}
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+export function ChatPlatformView() {
+ const { user } = useAuthContext();
+ const [chats, setChats] = useState([]);
+ const [contacts, setContacts] = useState([]);
+ const [groups, setGroups] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [selectedChat, setSelectedChat] = useState(null);
+ const [newChatOpen, setNewChatOpen] = useState(false);
+ const [startingId, setStartingId] = useState(null);
+ const [creatingGroupId, setCreatingGroupId] = useState(null);
+ const [error, setError] = useState(null);
+ const [tab, setTab] = useState(0);
+
+ const isMentor = user?.role === 'mentor';
+ const mobileShowWindow = !!selectedChat;
+
+ const load = useCallback(async () => {
+ try {
+ setLoading(true);
+ const tasks = [
+ getConversations({ page_size: 100 }),
+ isMentor
+ ? getStudents({ page_size: 100 }).then((r) => r.results.map((s) => ({ ...s.user, id: s.user?.id })).filter(Boolean))
+ : getMyMentors().then((list) => (Array.isArray(list) ? list : [])),
+ isMentor ? getGroups() : Promise.resolve([]),
+ ];
+ const [chatsRes, contactsRes, groupsRes] = await Promise.allSettled(tasks);
+ if (chatsRes.status === 'fulfilled') setChats(chatsRes.value.results ?? []);
+ if (contactsRes.status === 'fulfilled') setContacts(contactsRes.value ?? []);
+ if (groupsRes.status === 'fulfilled') setGroups(groupsRes.value ?? []);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
+ } finally {
+ setLoading(false);
+ }
+ }, [isMentor]);
+
+ useEffect(() => { load(); }, [load]);
+
+ const handleChatCreated = useCallback((rawChat) => {
+ const chat = normalizeChat(rawChat);
+ setChats((prev) => {
+ const exists = prev.some((c) => c.uuid === chat.uuid || c.id === chat.id);
+ if (exists) return prev;
+ return [chat, ...prev];
+ });
+ setSelectedChat(chat);
+ }, []);
+
+ const handleStartChat = useCallback(async (contact) => {
+ const existing = chats.find((c) => c.participant_id === contact.id);
+ if (existing) { setSelectedChat(existing); return; }
+ try {
+ setStartingId(contact.id);
+ const rawChat = await createChat(contact.id);
+ handleChatCreated({
+ ...rawChat,
+ other_participant: rawChat?.other_participant ?? {
+ id: contact.id,
+ first_name: contact.first_name,
+ last_name: contact.last_name,
+ avatar_url: contact.avatar_url || contact.avatar || null,
+ },
+ });
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка создания чата');
+ } finally {
+ setStartingId(null);
+ }
+ }, [chats, handleChatCreated]);
+
+ const handleCreateGroupChat = useCallback(async (group) => {
+ // Проверяем нет ли уже чата для этой группы
+ const existing = chats.find((c) => c.chat_type === 'group' && c.group === group.id);
+ if (existing) { setSelectedChat(existing); return; }
+ try {
+ setCreatingGroupId(group.id);
+ const rawChat = await createGroupChat(group.id);
+ handleChatCreated(rawChat);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка создания группового чата');
+ } finally {
+ setCreatingGroupId(null);
+ }
+ }, [chats, handleChatCreated]);
+
+ // Когда переключаем таб - снимаем выбор чата
+ const handleTabChange = (v) => {
+ setTab(v);
+ setSelectedChat(null);
+ };
+
+ return (
+
+
+
+
+
+ {error && (
+ setError(null)}>
+ {error}
+
+ )}
+
+
+
+ setNewChatOpen(true)}
+ userRole={user?.role}
+ />
+
+
+
+ setSelectedChat(null)}
+ />
+
+
+
+ setNewChatOpen(false)}
+ onCreated={handleChatCreated}
+ />
+
+ );
+}
diff --git a/front_minimal/src/sections/error/403-view.jsx b/front_minimal/src/sections/error/403-view.jsx
index eaeb3b6..5fd2f69 100644
--- a/front_minimal/src/sections/error/403-view.jsx
+++ b/front_minimal/src/sections/error/403-view.jsx
@@ -20,14 +20,13 @@ export function View403() {
- No permission
+ Доступ запрещён
-
- The page you’re trying to access has restricted access. Please refer to your system
- administrator.
+
+ У вас нет прав для просмотра этой страницы.
@@ -36,7 +35,7 @@ export function View403() {
diff --git a/front_minimal/src/sections/error/500-view.jsx b/front_minimal/src/sections/error/500-view.jsx
index 06b1e35..a95d82c 100644
--- a/front_minimal/src/sections/error/500-view.jsx
+++ b/front_minimal/src/sections/error/500-view.jsx
@@ -20,13 +20,13 @@ export function View500() {
- 500 Internal server error
+ 500 — Ошибка сервера
- There was an error, please try again later.
+ Что-то пошло не так. Пожалуйста, попробуйте позже.
@@ -35,7 +35,7 @@ export function View500() {
diff --git a/front_minimal/src/sections/error/not-found-view.jsx b/front_minimal/src/sections/error/not-found-view.jsx
index 80e8e2d..ee3ab1e 100644
--- a/front_minimal/src/sections/error/not-found-view.jsx
+++ b/front_minimal/src/sections/error/not-found-view.jsx
@@ -20,14 +20,13 @@ export function NotFoundView() {
- Sorry, page not found!
+ Страница не найдена
-
- Sorry, we couldn’t find the page you’re looking for. Perhaps you’ve mistyped the URL? Be
- sure to check your spelling.
+
+ Такой страницы не существует. Возможно, вы ошиблись в адресе или страница была удалена.
@@ -36,7 +35,7 @@ export function NotFoundView() {
diff --git a/front_minimal/src/sections/homework/homework-details-drawer.jsx b/front_minimal/src/sections/homework/homework-details-drawer.jsx
index 0566e85..6b8e261 100644
--- a/front_minimal/src/sections/homework/homework-details-drawer.jsx
+++ b/front_minimal/src/sections/homework/homework-details-drawer.jsx
@@ -17,6 +17,7 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import CircularProgress from '@mui/material/CircularProgress';
+import { resolveMediaUrl } from 'src/utils/axios';
import {
getMySubmission,
gradeSubmission,
@@ -24,20 +25,13 @@ import {
checkSubmissionWithAi,
getHomeworkSubmissions,
returnSubmissionForRevision,
-} from 'src/utils/homework-api';
-
-import { CONFIG } from 'src/config-global';
+} from 'src/utils/homework-api';
import { Iconify } from 'src/components/iconify';
// ----------------------------------------------------------------------
-function fileUrl(href) {
- if (!href) return '';
- if (href.startsWith('http://') || href.startsWith('https://')) return href;
- const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
- return base + (href.startsWith('/') ? href : `/${href}`);
-}
+const fileUrl = (href) => resolveMediaUrl(href);
function formatDateTime(s) {
if (!s) return '—';
@@ -465,6 +459,11 @@ export function HomeworkDetailsDrawer({ open, homework, userRole, childId, onClo
>
{homework.title}
+ {isMentor && Array.isArray(homework.assigned_to) && homework.assigned_to.length > 1 && (
+
+ Групповое ДЗ • {homework.assigned_to.length} учеников
+
+ )}
{homework.deadline && (
Дедлайн: {formatDateTime(homework.deadline)}
diff --git a/front_minimal/src/sections/homework/homework-submit-drawer.jsx b/front_minimal/src/sections/homework/homework-submit-drawer.jsx
index 3286a66..9989525 100644
--- a/front_minimal/src/sections/homework/homework-submit-drawer.jsx
+++ b/front_minimal/src/sections/homework/homework-submit-drawer.jsx
@@ -12,20 +12,14 @@ import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress';
+import { resolveMediaUrl } from 'src/utils/axios';
import { submitHomework, getHomeworkById, validateHomeworkFiles } from 'src/utils/homework-api';
-
-import { CONFIG } from 'src/config-global';
-
+
import { Iconify } from 'src/components/iconify';
// ----------------------------------------------------------------------
-function fileUrl(href) {
- if (!href) return '';
- if (href.startsWith('http://') || href.startsWith('https://')) return href;
- const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
- return base + (href.startsWith('/') ? href : `/${href}`);
-}
+const fileUrl = (href) => resolveMediaUrl(href);
// ----------------------------------------------------------------------
diff --git a/front_minimal/src/sections/homework/view/index.js b/front_minimal/src/sections/homework/view/index.js
index e363f6b..4cdd6b8 100644
--- a/front_minimal/src/sections/homework/view/index.js
+++ b/front_minimal/src/sections/homework/view/index.js
@@ -1 +1,2 @@
export * from './homework-view';
+export * from './homework-detail-view';
diff --git a/front_minimal/src/sections/materials/view/materials-view.jsx b/front_minimal/src/sections/materials/view/materials-view.jsx
index b8fea10..59cb31b 100644
--- a/front_minimal/src/sections/materials/view/materials-view.jsx
+++ b/front_minimal/src/sections/materials/view/materials-view.jsx
@@ -1,411 +1,1441 @@
-'use client';
-
-import { useState, useEffect, useCallback } from 'react';
-
-import Box from '@mui/material/Box';
-import Card from '@mui/material/Card';
-import Grid from '@mui/material/Grid';
-import Stack from '@mui/material/Stack';
-import Alert from '@mui/material/Alert';
-import Button from '@mui/material/Button';
-import Dialog from '@mui/material/Dialog';
-import Tooltip from '@mui/material/Tooltip';
-import TextField from '@mui/material/TextField';
-import Typography from '@mui/material/Typography';
-import IconButton from '@mui/material/IconButton';
-import DialogTitle from '@mui/material/DialogTitle';
-import CardContent from '@mui/material/CardContent';
-import DialogContent from '@mui/material/DialogContent';
-import DialogActions from '@mui/material/DialogActions';
-import InputAdornment from '@mui/material/InputAdornment';
-import CircularProgress from '@mui/material/CircularProgress';
-
+'use client';
+
+import { useRef, useMemo, useState, useEffect, useCallback } from 'react';
+
+import Box from '@mui/material/Box';
+import Chip from '@mui/material/Chip';
+import Stack from '@mui/material/Stack';
+import Table from '@mui/material/Table';
+import Alert from '@mui/material/Alert';
+import Paper from '@mui/material/Paper';
+import Radio from '@mui/material/Radio';
+import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import Drawer from '@mui/material/Drawer';
+import Divider from '@mui/material/Divider';
+import Tooltip from '@mui/material/Tooltip';
+import MenuList from '@mui/material/MenuList';
+import MenuItem from '@mui/material/MenuItem';
+import Skeleton from '@mui/material/Skeleton';
+import TableRow from '@mui/material/TableRow';
+import { useTheme } from '@mui/material/styles';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableHead from '@mui/material/TableHead';
+import TextField from '@mui/material/TextField';
+import IconButton from '@mui/material/IconButton';
+import Typography from '@mui/material/Typography';
+import RadioGroup from '@mui/material/RadioGroup';
+import LoadingButton from '@mui/lab/LoadingButton';
+import DialogTitle from '@mui/material/DialogTitle';
+import Autocomplete from '@mui/material/Autocomplete';
+import ToggleButton from '@mui/material/ToggleButton';
+import DialogActions from '@mui/material/DialogActions';
+import DialogContent from '@mui/material/DialogContent';
+import InputAdornment from '@mui/material/InputAdornment';
+import TableContainer from '@mui/material/TableContainer';
+import FormControlLabel from '@mui/material/FormControlLabel';
+import CircularProgress from '@mui/material/CircularProgress';
+import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
+
import { paths } from 'src/routes/paths';
-
-import {
- getMaterials,
- createMaterial,
- deleteMaterial,
- getMaterialTypeIcon,
+
+import { useBoolean } from 'src/hooks/use-boolean';
+
+import { fData } from 'src/utils/format-number';
+import { resolveMediaUrl } from 'src/utils/axios';
+import { getStudents } from 'src/utils/students-api';
+import { getGroups } from 'src/utils/groups-api';
+import {
+ getMaterials,
+ shareMaterial,
+ createMaterial,
+ deleteMaterial,
+ updateMaterial,
+ getMaterialById,
} from 'src/utils/materials-api';
-
-import { CONFIG } from 'src/config-global';
+
+import { CONFIG } from 'src/config-global';
import { DashboardContent } from 'src/layouts/dashboard';
-
-import { Iconify } from 'src/components/iconify';
+
+import { Iconify } from 'src/components/iconify';
+import { Scrollbar } from 'src/components/scrollbar';
+import { fileThumb } from 'src/components/file-thumbnail';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
-
-import { useAuthContext } from 'src/auth/hooks';
-
-// ----------------------------------------------------------------------
-
-function fileUrl(href) {
- if (!href) return '';
- if (href.startsWith('http://') || href.startsWith('https://')) return href;
- const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
- return base + (href.startsWith('/') ? href : `/${href}`);
-}
-
-function formatSize(bytes) {
- if (!bytes) return '';
- if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
- if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
- return `${bytes} Б`;
-}
-
-// ----------------------------------------------------------------------
-
-function MaterialCard({ material, onDelete, isMentor }) {
- const icon = getMaterialTypeIcon(material);
- const url = fileUrl(material.file_url || material.file || '');
- const isImage =
- material.material_type === 'image' ||
- /\.(jpe?g|png|gif|webp)$/i.test(material.file_name || material.file || '');
-
- return (
-
- {isImage && url ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
-
- {material.title}
-
- {material.description && (
-
- {material.description}
-
- )}
- {(material.file_size || material.category_name) && (
-
- {material.category_name && (
-
- {material.category_name}
-
- )}
- {material.file_size && (
-
- {formatSize(material.file_size)}
-
- )}
-
- )}
-
-
-
- {url && (
-
-
-
-
-
- )}
- {isMentor && (
-
- onDelete(material)}>
-
-
-
- )}
-
-
- );
-}
-
-// ----------------------------------------------------------------------
-
-function UploadDialog({ open, onClose, onSuccess }) {
- const [title, setTitle] = useState('');
- const [description, setDescription] = useState('');
- const [file, setFile] = useState(null);
- const [isPublic, setIsPublic] = useState(false);
- const [loading, setLoading] = useState(false);
- const [error, setError] = useState(null);
-
- const reset = () => {
- setTitle('');
- setDescription('');
- setFile(null);
- setIsPublic(false);
- setError(null);
- };
-
- const handleClose = () => {
- if (!loading) {
- reset();
- onClose();
- }
- };
-
- const handleSubmit = async (e) => {
- e.preventDefault();
- if (!title.trim()) {
- setError('Укажите название');
- return;
- }
- if (!file) {
- setError('Выберите файл');
- return;
- }
- try {
- setLoading(true);
- setError(null);
- await createMaterial({ title: title.trim(), description: description.trim(), file, is_public: isPublic });
- await onSuccess();
- handleClose();
- } catch (err) {
- setError(err?.response?.data?.detail || err?.message || 'Ошибка загрузки');
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
- );
-}
-
-// ----------------------------------------------------------------------
-
-export function MaterialsView() {
- const { user } = useAuthContext();
- const isMentor = user?.role === 'mentor';
-
- const [materials, setMaterials] = useState([]);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
- const [search, setSearch] = useState('');
- const [uploadOpen, setUploadOpen] = useState(false);
- const [deleteTarget, setDeleteTarget] = useState(null);
- const [deleting, setDeleting] = useState(false);
-
- const load = useCallback(async () => {
- try {
- setLoading(true);
- const res = await getMaterials({ page_size: 200 });
- setMaterials(res.results);
- } catch (e) {
- setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
- } finally {
- setLoading(false);
- }
- }, []);
-
- useEffect(() => {
- load();
- }, [load]);
-
- const handleDelete = async () => {
- if (!deleteTarget) return;
- try {
- setDeleting(true);
- await deleteMaterial(deleteTarget.id);
- setMaterials((prev) => prev.filter((m) => m.id !== deleteTarget.id));
- setDeleteTarget(null);
- } catch (e) {
- setError(e?.response?.data?.detail || e?.message || 'Ошибка удаления');
- } finally {
- setDeleting(false);
- }
- };
-
- const filtered = materials.filter((m) => {
- if (!search.trim()) return true;
- const q = search.toLowerCase();
- return (
- (m.title || '').toLowerCase().includes(q) ||
- (m.description || '').toLowerCase().includes(q) ||
- (m.category_name || '').toLowerCase().includes(q)
- );
- });
-
- return (
-
- }
- onClick={() => setUploadOpen(true)}
- >
- Добавить
-
- )
- }
- sx={{ mb: 3 }}
- />
-
-
- setSearch(e.target.value)}
- size="small"
- sx={{ width: 300 }}
- InputProps={{
- startAdornment: (
-
-
-
- ),
- }}
- />
-
-
- {error && (
-
- {error}
-
- )}
-
- {loading ? (
-
-
-
- ) : filtered.length === 0 ? (
-
- {search ? 'Ничего не найдено' : 'Нет материалов'}
-
- ) : (
-
- {filtered.map((material) => (
-
-
-
- ))}
-
- )}
-
- {/* Upload */}
- setUploadOpen(false)} onSuccess={load} />
-
- {/* Delete confirm */}
-
-
- );
-}
+import { usePopover, CustomPopover } from 'src/components/custom-popover';
+
+import { useAuthContext } from 'src/auth/hooks';
+
+// ----------------------------------------------------------------------
+
+const resolveUrl = (href) => resolveMediaUrl(href);
+
+function formatDate(s) {
+ if (!s) return '—';
+ const d = new Date(s);
+ return Number.isNaN(d.getTime())
+ ? '—'
+ : d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
+}
+
+// Detect format from filename (reliable — strips ?query params)
+function getMaterialFormat(material) {
+ const name = (material.file_name || material.file || material.file_url || '').split('?')[0];
+ const ext = name.split('.').pop().toLowerCase();
+ if (['jpg', 'jpeg', 'gif', 'bmp', 'png', 'svg', 'webp', 'ico', 'tiff', 'tif'].includes(ext)) return 'image';
+ if (['mp4', 'webm', 'mov', 'avi', 'mpg', 'm4v', 'mkv', 'flv', 'wmv'].includes(ext)) return 'video';
+ if (['mp3', 'wav', 'ogg', 'aac', 'aif', 'm4a', 'flac', 'wma', 'opus'].includes(ext)) return 'audio';
+ if (ext === 'pdf') return 'pdf';
+ if (['doc', 'docx', 'odt', 'rtf'].includes(ext)) return 'word';
+ if (['xls', 'xlsx', 'ods', 'csv'].includes(ext)) return 'excel';
+ if (['ppt', 'pptx', 'odp', 'key'].includes(ext)) return 'powerpoint';
+ if (['zip', 'rar', '7z', 'iso', 'tar', 'gz', 'bz2', 'xz'].includes(ext)) return 'zip';
+ if (['txt', 'md', 'markdown', 'rst', 'log'].includes(ext)) return 'txt';
+ if ([
+ 'js', 'jsx', 'ts', 'tsx', 'py', 'java', 'c', 'cpp', 'cs', 'go', 'rs',
+ 'rb', 'php', 'swift', 'kt', 'r', 'sql', 'sh', 'bash', 'zsh', 'ps1',
+ 'html', 'htm', 'css', 'scss', 'sass', 'less', 'xml', 'json', 'yaml',
+ 'yml', 'toml', 'ini', 'vue', 'svelte', 'dart', 'lua', 'pl', 'ex', 'exs',
+ ].includes(ext)) return 'code';
+ return 'file';
+}
+
+// Whether this format can be viewed inline (text fetched from server)
+const TEXT_VIEWABLE = new Set(['txt', 'md', 'markdown', 'rst', 'log',
+ 'js', 'jsx', 'ts', 'tsx', 'py', 'java', 'c', 'cpp', 'cs', 'go', 'rs',
+ 'rb', 'php', 'swift', 'kt', 'r', 'sql', 'sh', 'bash', 'zsh', 'ps1',
+ 'html', 'htm', 'css', 'scss', 'sass', 'less', 'xml', 'json', 'yaml',
+ 'yml', 'toml', 'ini', 'vue', 'svelte', 'dart', 'lua', 'pl', 'ex', 'exs',
+ 'csv',
+]);
+
+function isTextViewable(material) {
+ const name = (material.file_name || material.file || material.file_url || '').split('?')[0];
+ const ext = name.split('.').pop().toLowerCase();
+ return TEXT_VIEWABLE.has(ext);
+}
+
+const TYPE_LABELS = {
+ image: 'Изображение',
+ video: 'Видео',
+ audio: 'Аудио',
+ pdf: 'PDF',
+ word: 'Документ',
+ excel: 'Таблица',
+ powerpoint: 'Презентация',
+ zip: 'Архив',
+ txt: 'Текст',
+ code: 'Код',
+ file: 'Файл',
+};
+
+const FILTER_TYPES = [
+ { key: 'all', label: 'Все' },
+ { key: 'image', label: 'Изображения' },
+ { key: 'document', label: 'Документы', formats: ['pdf', 'word', 'excel', 'txt'] },
+ { key: 'powerpoint', label: 'Презентации' },
+ { key: 'code', label: 'Программирование' },
+ { key: 'video', label: 'Видео' },
+ { key: 'audio', label: 'Аудио' },
+ { key: 'zip', label: 'Архивы' },
+];
+
+function matchesTypeFilter(material, filterKey) {
+ if (filterKey === 'all') return true;
+ const fmt = getMaterialFormat(material);
+ const filter = FILTER_TYPES.find((f) => f.key === filterKey);
+ if (!filter) return true;
+ if (filter.formats) return filter.formats.includes(fmt);
+ return fmt === filterKey;
+}
+
+function materialThumb(fmt) {
+ if (fmt === 'code') return `${CONFIG.site.basePath}/assets/icons/files/ic-js.svg`;
+ return fileThumb(fmt);
+}
+
+// ----------------------------------------------------------------------
+// PPTX viewer
+
+function PptxViewer({ url }) {
+ const containerRef = useRef(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!url || !containerRef.current) return;
+ setLoading(true);
+ setError(null);
+
+ const container = containerRef.current;
+ // Clear previous render
+ container.innerHTML = '';
+
+ Promise.all([
+ fetch(url, { credentials: 'include' }).then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.arrayBuffer();
+ }),
+ import('pptx-preview'),
+ ])
+ .then(([buffer, { init }]) => {
+ const w = container.clientWidth || 960;
+ const h = Math.round(w * (9 / 16));
+ const previewer = init(container, { width: w, height: h });
+ return previewer.preview(buffer);
+ })
+ .catch((e) => setError(e.message || 'Ошибка рендеринга презентации'))
+ .finally(() => setLoading(false));
+ }, [url]);
+
+ return (
+
+ {loading && (
+
+
+
+ )}
+ {error && {error}}
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+// Fullscreen file viewer dialog
+
+function DocxViewer({ url, isDark }) {
+ const containerRef = useRef(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!url || !containerRef.current) return;
+ setLoading(true);
+ setError(null);
+
+ Promise.all([
+ fetch(url, { credentials: 'include' }).then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.blob();
+ }),
+ import('docx-preview'),
+ ])
+ .then(([blob, { renderAsync }]) => {
+ const container = containerRef.current;
+ if (!container) return null;
+ return renderAsync(blob, container, null, {
+ className: 'docx-viewer',
+ inWrapper: true,
+ ignoreWidth: false,
+ ignoreHeight: false,
+ ignoreFonts: false,
+ breakPages: true,
+ useBase64URL: true,
+ });
+ })
+ .catch((e) => setError(e.message || 'Ошибка рендеринга документа'))
+ .finally(() => setLoading(false));
+ }, [url]);
+
+ return (
+
+ {loading && (
+
+
+
+ )}
+ {error && {error}}
+
+
+ );
+}
+
+function MaterialViewerDialog({ open, material, onClose }) {
+ const theme = useTheme();
+ const isDark = theme.palette.mode === 'dark';
+
+ const [textContent, setTextContent] = useState(null);
+ const [textLoading, setTextLoading] = useState(false);
+ const [textError, setTextError] = useState(null);
+
+ const fmt = material ? getMaterialFormat(material) : 'file';
+ const url = material ? resolveUrl(material.file_url || material.file || '') : '';
+ const canViewText = material ? isTextViewable(material) : false;
+ const fileName = material
+ ? (material.file_name || material.file || material.file_url || '').split('?')[0].toLowerCase()
+ : '';
+ const isDocx = fileName.endsWith('.docx');
+ const isPptx = fileName.endsWith('.pptx') || fileName.endsWith('.ppt');
+
+ useEffect(() => {
+ if (!open || !material || !canViewText || !url) return;
+ setTextContent(null);
+ setTextError(null);
+ setTextLoading(true);
+ fetch(url, { credentials: 'include' })
+ .then((r) => {
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ return r.text();
+ })
+ .then((t) => setTextContent(t))
+ .catch((e) => setTextError(e.message || 'Ошибка загрузки файла'))
+ .finally(() => setTextLoading(false));
+ }, [open, url, canViewText, material]);
+
+ if (!material) return null;
+
+ const renderContent = () => {
+ // Image
+ if (fmt === 'image' && url) {
+ return (
+
+
+
+ );
+ }
+
+ // Video
+ if (fmt === 'video' && url) {
+ return (
+
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
+
+
+ );
+ }
+
+ // Audio
+ if (fmt === 'audio' && url) {
+ return (
+
+
+ {material.file_name || material.title}
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
+
+
+ );
+ }
+
+ // PDF
+ if (fmt === 'pdf' && url) {
+ return (
+
+ );
+ }
+
+ // DOCX
+ if (isDocx && url) {
+ return ;
+ }
+
+ // PPTX
+ if (isPptx && url) {
+ return ;
+ }
+
+ // Text / Code (fetched from server)
+ if (canViewText) {
+ if (textLoading) {
+ return (
+
+
+
+ );
+ }
+ if (textError) {
+ return (
+
+ {textError}
+ {url && (
+ }>
+ Скачать файл
+
+ )}
+
+ );
+ }
+ if (textContent !== null) {
+ const lineCount = textContent.split('\n').length;
+ // Theme-aware code editor colors
+ const codeBg = isDark ? '#1a1d23' : '#f5f6f8';
+ const gutterBg = isDark ? '#14161b' : '#eceef2';
+ const gutterColor = isDark ? '#4a5068' : '#9aa0b8';
+ const gutterBorder = isDark ? '#2a2d38' : '#dde0ea';
+ const codeColor = isDark ? '#e2e4ed' : '#1f2437';
+
+ return (
+
+ {/* Line numbers */}
+
+ {Array.from({ length: lineCount }, (_, i) => (
+ {i + 1}
+ ))}
+
+ {/* Code content */}
+
+ {textContent}
+
+
+ );
+ }
+ }
+
+ // Unsupported — show download prompt
+ return (
+
+
+
+ Предпросмотр недоступен для данного типа файла
+
+ {url && (
+ }
+ >
+ Скачать файл
+
+ )}
+
+ );
+ };
+
+ return (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+// Access settings
+
+const ACCESS_OPTIONS = [
+ { value: 'private', label: 'Приватный', desc: 'Только вы и выбранные ученики' },
+ { value: 'clients', label: 'Все мои ученики', desc: 'Доступно всем вашим ученикам' },
+ { value: 'public', label: 'Публичный', desc: 'Доступно всем пользователям' },
+];
+
+// ----------------------------------------------------------------------
+// 3-dot menu
+
+function MaterialMenu({ material, isMentor, onView, onDetails, onDelete }) {
+ const popover = usePopover();
+ const url = resolveUrl(material.file_url || material.file || '');
+
+ return (
+ <>
+
+
+
+
+
+
+ { popover.onClose(); onView(); }}>
+
+ Просмотр
+
+
+ { popover.onClose(); onDetails(); }}>
+
+ Подробнее
+
+
+ {url && (
+
+
+ Скачать
+
+ )}
+
+ {isMentor && (
+ <>
+
+ { popover.onClose(); onDetails(); }}>
+
+ Поделиться с группой
+
+ { popover.onClose(); onDelete(); }}
+ sx={{ color: 'error.main' }}
+ >
+
+ Удалить
+
+ >
+ )}
+
+
+ >
+ );
+}
+
+// ----------------------------------------------------------------------
+// Grid card
+
+function MaterialGridItem({ material, isMentor, onView, onDetails, onDelete }) {
+ const fmt = getMaterialFormat(material);
+ const isImg = fmt === 'image';
+ const url = resolveUrl(material.file_url || material.file || '');
+
+ return (
+
+ {/* Thumbnail */}
+ {isImg && url ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {/* Info + menu */}
+
+
+
+ {material.title}
+
+
+ {material.file_size && (
+ {fData(material.file_size)}
+ )}
+
+
+ e.stopPropagation()}>
+
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+// Table row
+
+function MaterialTableRow({ material, isMentor, onView, onDetails, onDelete }) {
+ const fmt = getMaterialFormat(material);
+ const url = resolveUrl(material.file_url || material.file || '');
+
+ return (
+
+
+
+
+
+
+
+ {material.title}
+ {material.description && (
+
+ {material.description}
+
+ )}
+
+
+
+
+
+ {TYPE_LABELS[fmt] || fmt}
+
+
+
+
+ {material.file_size ? fData(material.file_size) : '—'}
+
+
+
+
+
+ {formatDate(material.created_at || material.uploaded_at)}
+
+
+
+ e.stopPropagation()}>
+
+ {url && (
+
+
+
+
+
+ )}
+
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+// Details / access drawer
+
+function MaterialDetailsDrawer({ open, material, onClose, onDelete, onView, onAccessSaved, isMentor }) {
+ const [full, setFull] = useState(null);
+ const [accessType, setAccessType] = useState('private');
+ const [sharedWith, setSharedWith] = useState([]);
+ const [students, setStudents] = useState([]);
+ const [accessSaving, setAccessSaving] = useState(false);
+ const [accessError, setAccessError] = useState(null);
+ const [accessSuccess, setAccessSuccess] = useState(false);
+
+ // Group sharing state
+ const [groups, setGroups] = useState([]);
+ const [selectedGroupId, setSelectedGroupId] = useState('');
+ const [groupSharing, setGroupSharing] = useState(false);
+ const [groupShareSuccess, setGroupShareSuccess] = useState(false);
+ const [groupShareError, setGroupShareError] = useState('');
+
+ // Fetch full detail (shared_with not in list serializer)
+ useEffect(() => {
+ if (!open || !material?.id) { setFull(null); return; }
+ setAccessError(null);
+ setAccessSuccess(false);
+ getMaterialById(material.id)
+ .then((data) => {
+ setFull(data);
+ setAccessType(data.access_type || 'private');
+ setSharedWith(data.shared_with || []);
+ })
+ .catch(() => {
+ setAccessType(material.access_type || 'private');
+ setSharedWith(material.shared_with || []);
+ });
+ }, [open, material?.id]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ useEffect(() => {
+ if (!isMentor || !open) return;
+ getStudents({ page_size: 200 })
+ .then((res) => setStudents(res.results || []))
+ .catch(() => {});
+ getGroups()
+ .then(setGroups)
+ .catch(() => {});
+ setSelectedGroupId('');
+ setGroupShareSuccess(false);
+ setGroupShareError('');
+ }, [isMentor, open]);
+
+ const studentOptions = useMemo(
+ () => students.map((s) => ({
+ id: s.user?.id ?? s.id,
+ label: [s.user?.first_name, s.user?.last_name].filter(Boolean).join(' ') || s.user?.email || `#${s.id}`,
+ })),
+ [students]
+ );
+
+ const handleShareWithGroup = async () => {
+ if (!selectedGroupId || !material) return;
+ const group = groups.find((g) => g.id === selectedGroupId);
+ if (!group) return;
+ const studentUserIds = (group.students || [])
+ .map((s) => s.user?.id ?? s.id)
+ .filter(Boolean);
+ if (studentUserIds.length === 0) {
+ setGroupShareError('В группе нет участников');
+ return;
+ }
+ setGroupSharing(true);
+ setGroupShareSuccess(false);
+ setGroupShareError('');
+ try {
+ await shareMaterial(material.id, studentUserIds);
+ setGroupShareSuccess(true);
+ } catch (e) {
+ setGroupShareError(e?.response?.data?.detail || e?.message || 'Ошибка отправки');
+ } finally {
+ setGroupSharing(false);
+ }
+ };
+
+ const handleSaveAccess = async () => {
+ if (!material) return;
+ setAccessSaving(true);
+ setAccessError(null);
+ setAccessSuccess(false);
+ try {
+ await updateMaterial(material.id, { access_type: accessType });
+ await shareMaterial(material.id, sharedWith.map((u) => u.id));
+ setAccessSuccess(true);
+ if (onAccessSaved) onAccessSaved({ ...material, access_type: accessType, shared_with: sharedWith });
+ } catch (e) {
+ setAccessError(e?.response?.data?.detail || e?.message || 'Ошибка сохранения');
+ } finally {
+ setAccessSaving(false);
+ }
+ };
+
+ if (!material) return null;
+
+ const fmt = getMaterialFormat(material);
+ const isImg = fmt === 'image';
+ const isVideo = fmt === 'video';
+ const url = resolveUrl(material.file_url || material.file || '');
+
+ const infoRows = [
+ { label: 'Тип', value: TYPE_LABELS[fmt] || fmt },
+ { label: 'Размер', value: material.file_size ? fData(material.file_size) : '—' },
+ { label: 'Загружено', value: formatDate(material.created_at || material.uploaded_at) },
+ ];
+
+ return (
+
+
+ {material.title}
+
+
+
+
+
+ {/* Preview thumbnail */}
+ {isImg && url ? (
+
+
+
+ ) : isVideo && url ? (
+
+ {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
+
+
+ ) : (
+
+
+
+ )}
+
+ {/* Description */}
+ {material.description && (
+
+ Описание
+
+ {material.description}
+
+
+ )}
+
+
+
+
+ {infoRows.map(({ label, value }) => (
+
+ {label}
+ {value}
+
+ ))}
+
+
+ {/* Actions */}
+
+ }
+ onClick={() => { onClose(); onView(); }}
+ >
+ Открыть
+
+ {url && (
+ }
+ component="a" href={url} target="_blank" rel="noopener noreferrer"
+ >
+ Скачать
+
+ )}
+
+
+ {/* Access settings — mentor only */}
+ {isMentor && (
+ <>
+
+ Настройки доступа
+
+ { setAccessType(e.target.value); setAccessSuccess(false); }}
+ >
+ {ACCESS_OPTIONS.map((opt) => (
+ }
+ label={
+
+ {opt.label}
+ {opt.desc}
+
+ }
+ sx={{ mb: 0.5, alignItems: 'flex-start', '& .MuiRadio-root': { mt: 0.25 } }}
+ />
+ ))}
+
+
+
+
+ Доступ конкретным ученикам
+
+ {
+ const found = studentOptions.find((s) => s.id === u.id);
+ return found || { id: u.id, label: [u.first_name, u.last_name].filter(Boolean).join(' ') || u.email || `#${u.id}` };
+ })}
+ onChange={(_, val) => {
+ setSharedWith(val.map((v) => ({ id: v.id, first_name: v.label })));
+ setAccessSuccess(false);
+ }}
+ getOptionLabel={(o) => o.label}
+ isOptionEqualToValue={(a, b) => a.id === b.id}
+ renderInput={(params) => (
+
+ )}
+ renderTags={(val, getTagProps) =>
+ val.map((opt, idx) => (
+
+ ))
+ }
+ noOptionsText="Нет учеников"
+ />
+
+
+ {accessError && {accessError}}
+ {accessSuccess && Настройки доступа сохранены}
+
+ }
+ sx={{ mt: 1.5 }}
+ >
+ Сохранить доступ
+
+
+ {/* Share with group */}
+ {groups.length > 0 && (
+ <>
+
+
+
+
+ Поделиться с группой
+
+ g.id === selectedGroupId) ?? null}
+ onChange={(_, val) => {
+ setSelectedGroupId(val?.id ?? '');
+ setGroupShareSuccess(false);
+ setGroupShareError('');
+ }}
+ getOptionLabel={(g) => g.name}
+ isOptionEqualToValue={(a, b) => a.id === b.id}
+ renderOption={(props, g) => (
+
+
+
+
+
+
+ {g.name}
+
+ {g.students_count ?? g.students?.length ?? 0} участников
+
+
+
+
+ )}
+ renderInput={(params) => (
+
+ )}
+ noOptionsText="Нет групп"
+ />
+
+
+ {groupShareError && (
+ {groupShareError}
+ )}
+ {groupShareSuccess && (
+
+ Материал отправлен всем участникам группы
+
+ )}
+
+ }
+ sx={{ mt: 1.5 }}
+ >
+ Поделиться с группой
+
+ >
+ )}
+
+
+
+ }
+ onClick={() => { onClose(); onDelete(material); }}
+ >
+ Удалить
+
+ >
+ )}
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+// Upload dialog
+
+function UploadDialog({ open, onClose, onSuccess }) {
+ const [title, setTitle] = useState('');
+ const [description, setDescription] = useState('');
+ const [file, setFile] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(null);
+
+ const reset = () => { setTitle(''); setDescription(''); setFile(null); setError(null); };
+ const handleClose = () => { if (!loading) { reset(); onClose(); } };
+
+ const handleSubmit = async () => {
+ if (!title.trim()) { setError('Укажите название'); return; }
+ if (!file) { setError('Выберите файл'); return; }
+ try {
+ setLoading(true);
+ setError(null);
+ await createMaterial({ title: title.trim(), description: description.trim(), file });
+ await onSuccess();
+ handleClose();
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+// Main
+
+export function MaterialsView() {
+ const { user } = useAuthContext();
+ const isMentor = user?.role === 'mentor';
+
+ const [materials, setMaterials] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ const [view, setView] = useState('grid');
+ const [search, setSearch] = useState('');
+ const [typeFilter, setTypeFilter] = useState('all');
+
+ const [selected, setSelected] = useState(null);
+ const details = useBoolean();
+
+ // Viewer
+ const [viewerMaterial, setViewerMaterial] = useState(null);
+ const viewer = useBoolean();
+
+ const [deleteTarget, setDeleteTarget] = useState(null);
+ const [deleting, setDeleting] = useState(false);
+
+ const upload = useBoolean();
+
+ const load = useCallback(async () => {
+ try {
+ setLoading(true);
+ const res = await getMaterials({ page_size: 500 });
+ setMaterials(res.results);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => { load(); }, [load]);
+
+ const handleOpenViewer = useCallback((material) => {
+ setViewerMaterial(material);
+ viewer.onTrue();
+ }, [viewer]);
+
+ const handleOpenDetails = useCallback((material) => {
+ setSelected(material);
+ details.onTrue();
+ }, [details]);
+
+ const handleDeleteRequest = useCallback((material) => {
+ setDeleteTarget(material);
+ }, []);
+
+ const handleDeleteConfirm = async () => {
+ if (!deleteTarget) return;
+ setDeleting(true);
+ try {
+ await deleteMaterial(deleteTarget.id);
+ setMaterials((prev) => prev.filter((m) => m.id !== deleteTarget.id));
+ setDeleteTarget(null);
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка удаления');
+ } finally {
+ setDeleting(false);
+ }
+ };
+
+ const filtered = materials.filter((m) => {
+ const q = search.toLowerCase();
+ const matchSearch = !q
+ || (m.title || '').toLowerCase().includes(q)
+ || (m.description || '').toLowerCase().includes(q);
+ return matchSearch && matchesTypeFilter(m, typeFilter);
+ });
+
+ const typeCounts = FILTER_TYPES.map((f) => ({
+ ...f,
+ count: f.key === 'all'
+ ? materials.length
+ : materials.filter((m) => matchesTypeFilter(m, f.key)).length,
+ })).filter((f) => f.key === 'all' || f.count > 0);
+
+ return (
+ <>
+
+ {/* Header */}
+
+
+ {isMentor && (
+ } onClick={upload.onTrue}>
+ Загрузить
+
+ )}
+
+
+ {/* Toolbar */}
+
+ setSearch(e.target.value)}
+ size="small"
+ sx={{ flexGrow: 1, maxWidth: { sm: 360 } }}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ endAdornment: search && (
+
+ setSearch('')}>
+
+
+
+ ),
+ }}
+ />
+ v && setView(v)} sx={{ flexShrink: 0 }}>
+
+
+
+
+
+ {/* Type filter chips */}
+
+ {typeCounts.map((f) => (
+ setTypeFilter(f.key)}
+ color={typeFilter === f.key ? 'primary' : 'default'}
+ variant={typeFilter === f.key ? 'filled' : 'outlined'}
+ sx={{ mb: 0.5 }}
+ />
+ ))}
+
+
+ {error && {error}}
+
+ {/* Content */}
+ {loading ? (
+ view === 'grid' ? (
+
+ {Array.from({ length: 8 }).map((_, i) => )}
+
+ ) : (
+
+ {Array.from({ length: 6 }).map((_, i) => )}
+
+ )
+ ) : filtered.length === 0 ? (
+
+
+
+ {search || typeFilter !== 'all' ? 'Ничего не найдено' : 'Материалов пока нет'}
+
+
+ ) : view === 'grid' ? (
+
+ {filtered.map((m) => (
+ handleOpenViewer(m)}
+ onDetails={() => handleOpenDetails(m)}
+ onDelete={() => handleDeleteRequest(m)}
+ />
+ ))}
+
+ ) : (
+
+
+
+
+ Название
+ Тип
+ Размер
+ Дата
+
+
+
+
+ {filtered.map((m) => (
+ handleOpenViewer(m)}
+ onDetails={() => handleOpenDetails(m)}
+ onDelete={() => handleDeleteRequest(m)}
+ />
+ ))}
+
+
+
+ )}
+
+
+ {/* Fullscreen viewer */}
+
+
+ {/* Details / access drawer */}
+ { details.onFalse(); handleOpenViewer(selected); }}
+ onDelete={handleDeleteRequest}
+ onAccessSaved={(updated) => {
+ setSelected(updated);
+ setMaterials((prev) => prev.map((m) => (m.id === updated.id ? { ...m, ...updated } : m)));
+ }}
+ isMentor={isMentor}
+ />
+
+ {/* Upload */}
+
+
+ {/* Delete confirm */}
+
+ >
+ );
+}
diff --git a/front_minimal/src/sections/notifications/view/notifications-view.jsx b/front_minimal/src/sections/notifications/view/notifications-view.jsx
index 4870e3e..300ff97 100644
--- a/front_minimal/src/sections/notifications/view/notifications-view.jsx
+++ b/front_minimal/src/sections/notifications/view/notifications-view.jsx
@@ -18,16 +18,16 @@ import IconButton from '@mui/material/IconButton';
import ListItemText from '@mui/material/ListItemText';
import CircularProgress from '@mui/material/CircularProgress';
-import { paths } from 'src/routes/paths';
+import { paths } from 'src/routes/paths';
import {
markAsRead,
markAllAsRead,
getNotifications,
deleteNotification,
-} from 'src/utils/notifications-api';
+} from 'src/utils/notifications-api';
-import { DashboardContent } from 'src/layouts/dashboard';
+import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
@@ -140,11 +140,32 @@ export function NotificationsView() {
{error && {error}}
setTab(v)} sx={{ mb: 3 }}>
-
- 0 ? 1.5 : 0 }}>Непрочитанные
-
- } />
+
+ Непрочитанные
+ {unreadCount > 0 && (
+
+ {unreadCount}
+
+ )}
+
+ }
+ />
diff --git a/front_minimal/src/sections/overview/course/view/overview-course-view.jsx b/front_minimal/src/sections/overview/course/view/overview-course-view.jsx
index dd08e5c..ce9df84 100644
--- a/front_minimal/src/sections/overview/course/view/overview-course-view.jsx
+++ b/front_minimal/src/sections/overview/course/view/overview-course-view.jsx
@@ -11,6 +11,7 @@ import { useTheme } from '@mui/material/styles';
import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress';
+import { paths } from 'src/routes/paths';
import { useRouter } from 'src/routes/hooks';
import { CONFIG } from 'src/config-global';
import { varAlpha } from 'src/theme/styles';
@@ -58,7 +59,7 @@ function UpcomingLessonCard({ lesson }) {
setJoining(true);
const res = await createLiveKitRoom(lesson.id);
const token = res?.access_token || res?.token;
- router.push(`/video-call?token=${token}&lesson_id=${lesson.id}`);
+ router.push(`${paths.dashboard.prejoin}?token=${encodeURIComponent(token)}&lesson_id=${lesson.id}`);
} catch (err) {
console.error('Join error:', err);
setJoining(false);
@@ -230,6 +231,13 @@ export function OverviewCourseView() {
fetchData(period);
}, [period, fetchData]);
+ // Периодическое обновление ближайших занятий (каждые 60 сек) — чтобы кнопка «Подключиться»
+ // появлялась автоматически, когда до занятия остаётся ≤ 10 минут
+ useEffect(() => {
+ const id = setInterval(() => fetchData(period), 60_000);
+ return () => clearInterval(id);
+ }, [period, fetchData]);
+
if (loading && !stats) {
return (
@@ -294,19 +302,19 @@ export function OverviewCourseView() {
>
{/* ЛЕВАЯ ЧАСТЬ */}
-
+
-
-
+
+
{incomeChart && `${val} ₽` } } } }} onValueChange={(val) => setPeriod(val)} />}
-
+
{lessonsChart && `${val} занятий` } } } }} onValueChange={(val) => setPeriod(val)} />}
@@ -316,7 +324,7 @@ export function OverviewCourseView() {
{/* ПРАВАЯ ЧАСТЬ */}
-
+
{/* Ближайшие уроки */}
diff --git a/front_minimal/src/sections/payment/view/payment-platform-view.jsx b/front_minimal/src/sections/payment/view/payment-platform-view.jsx
index c4c470e..70f58d2 100644
--- a/front_minimal/src/sections/payment/view/payment-platform-view.jsx
+++ b/front_minimal/src/sections/payment/view/payment-platform-view.jsx
@@ -5,72 +5,607 @@ import { useState, useEffect, useCallback } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Chip from '@mui/material/Chip';
-import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert';
+import Stack from '@mui/material/Stack';
import Button from '@mui/material/Button';
+import Dialog from '@mui/material/Dialog';
+import Avatar from '@mui/material/Avatar';
import Divider from '@mui/material/Divider';
import Typography from '@mui/material/Typography';
import CardContent from '@mui/material/CardContent';
+import DialogTitle from '@mui/material/DialogTitle';
+import DialogContent from '@mui/material/DialogContent';
+import DialogActions from '@mui/material/DialogActions';
+import LinearProgress from '@mui/material/LinearProgress';
import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths';
import axios from 'src/utils/axios';
-import { DashboardContent } from 'src/layouts/dashboard';
+import { varAlpha } from 'src/theme/styles';
+import { AvatarShape } from 'src/assets/illustrations';
+import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
// ----------------------------------------------------------------------
-async function getPaymentInfo() {
- const res = await axios.get('/payments/subscriptions/');
- const {data} = res;
+async function getPlans() {
+ const res = await axios.get('/subscriptions/plans/');
+ const { data } = res;
if (Array.isArray(data)) return data;
return data?.results ?? [];
}
-async function getPaymentHistory() {
- const res = await axios.get('/payments/history/');
- const {data} = res;
- if (Array.isArray(data)) return data;
- return data?.results ?? [];
+async function getActiveSubscription() {
+ const res = await axios.get('/subscriptions/subscriptions/active/');
+ return res.data ?? null;
+}
+
+async function createSubscription(planId, durationDays) {
+ const res = await axios.post('/subscriptions/subscriptions/', {
+ plan_id: planId,
+ duration_days: durationDays,
+ });
+ return res.data;
+}
+
+async function cancelSubscription(id) {
+ const res = await axios.post(`/subscriptions/subscriptions/${id}/cancel/`);
+ return res.data;
}
// ----------------------------------------------------------------------
+const STATUS_LABELS = {
+ trial: { label: 'Пробная', color: 'info' },
+ active: { label: 'Активна', color: 'success' },
+ past_due: { label: 'Просрочена', color: 'warning' },
+ cancelled: { label: 'Отменена', color: 'error' },
+ expired: { label: 'Истекла', color: 'error' },
+};
+
+const BILLING_LABELS = {
+ monthly: 'мес',
+ quarterly: 'кварт',
+ yearly: 'год',
+ lifetime: 'навсегда',
+};
+
+function getPeriodLabel(plan) {
+ if (plan.duration_days) return `${plan.duration_days} дн.`;
+ return BILLING_LABELS[plan.billing_period] || '—';
+}
+
+function getPriceLabel(plan) {
+ if (Number(plan.price) === 0) return 'Бесплатно';
+ return `${plan.price} ₽`;
+}
+
+const FEATURE_ICONS = {
+ video_calls: { icon: 'eva:video-outline', label: 'Видеозвонки' },
+ screen_sharing: { icon: 'eva:monitor-outline', label: 'Демонстрация экрана' },
+ whiteboard: { icon: 'eva:edit-2-outline', label: 'Интерактивная доска' },
+ homework: { icon: 'eva:book-open-outline', label: 'Домашние задания' },
+ materials: { icon: 'eva:folder-outline', label: 'Материалы' },
+ analytics: { icon: 'eva:bar-chart-outline', label: 'Аналитика' },
+ telegram_bot: { icon: 'eva:paper-plane-outline', label: 'Telegram-бот' },
+ api_access: { icon: 'eva:code-outline', label: 'API-доступ' },
+};
+
+const PLAN_GRADIENTS = [
+ 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
+ 'linear-gradient(135deg, #f093fb 0%, #f5576c 100%)',
+ 'linear-gradient(135deg, #4facfe 0%, #00f2fe 100%)',
+ 'linear-gradient(135deg, #43e97b 0%, #38f9d7 100%)',
+ 'linear-gradient(135deg, #fa709a 0%, #fee140 100%)',
+ 'linear-gradient(135deg, #a18cd1 0%, #fbc2eb 100%)',
+];
+
+const PLAN_ICONS = [
+ 'eva:star-outline',
+ 'eva:award-outline',
+ 'eva:flash-outline',
+ 'eva:trending-up-outline',
+ 'eva:shield-outline',
+ 'eva:layers-outline',
+];
+
+// Special gradient for active subscription card
+const ACTIVE_GRADIENT = 'linear-gradient(135deg, #0cebeb 0%, #20e3b2 50%, #29ffc6 100%)';
+
function formatDate(ts) {
if (!ts) return '—';
try {
- return new Date(ts).toLocaleDateString('ru-RU');
+ return new Date(ts).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' });
} catch {
return ts;
}
}
-function formatAmount(amount, currency) {
- if (amount == null) return '—';
- return `${amount} ${currency || '₽'}`;
+function formatStorage(mb) {
+ if (!mb) return '—';
+ if (mb >= 1024) return `${(mb / 1024).toFixed(0)} ГБ`;
+ return `${mb} МБ`;
+}
+
+// ----------------------------------------------------------------------
+
+function UsageBar({ label, used, limit, unlimited, unit = '' }) {
+ if (unlimited) {
+ return (
+
+
+ {label}
+ ∞
+
+
+
+ );
+ }
+ if (!limit) return null;
+ const pct = Math.min(Math.round((used / limit) * 100), 100);
+ const color = pct >= 90 ? 'error' : pct >= 70 ? 'warning' : 'primary';
+ return (
+
+
+ {label}
+
+ {used ?? 0}{unit} / {limit}{unit}
+
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+// Shared banner/avatar block used by both cards
+function CardBanner({ gradient, icon, badgeTop, badgeTopRight }) {
+ return (
+
+
+
+ `0 8px 16px 0 ${varAlpha(theme.vars.palette.grey['500Channel'], 0.24)}`,
+ }}
+ >
+
+
+
+ {badgeTopRight && (
+
+ {badgeTopRight}
+
+ )}
+
+ varAlpha(theme.vars.palette.grey['900Channel'], 0.24),
+ },
+ }}
+ >
+ varAlpha(theme.vars.palette.common.whiteChannel, 0.24),
+ position: 'absolute',
+ top: -16,
+ left: -16,
+ }}
+ />
+ varAlpha(theme.vars.palette.common.whiteChannel, 0.16),
+ position: 'absolute',
+ bottom: -32,
+ right: -24,
+ }}
+ />
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function ActiveSubscriptionCard({ subscription, onCancel, cancelling }) {
+ const plan = subscription.plan || {};
+ const status = STATUS_LABELS[subscription.status] || { label: subscription.status, color: 'default' };
+ const usage = subscription.usage || {};
+ const features = plan.features || {};
+ const activeFeatures = Object.entries(FEATURE_ICONS).filter(([key]) => features[key]);
+
+ return (
+
+
+ }
+ />
+
+ {/* Name */}
+
+ {plan.name || 'Моя подписка'}
+ {plan.description && (
+
+ {plan.description}
+
+ )}
+
+
+ {/* Days left */}
+ {subscription.days_left != null && (
+
+
+
+ )}
+
+ {/* Features */}
+ {activeFeatures.length > 0 && (
+
+ {activeFeatures.map(([key, { label }]) => (
+
+
+ {label}
+
+ ))}
+
+ )}
+
+ {/* Usage bars */}
+ {Object.keys(usage).length > 0 && (
+
+ {usage.lessons && (
+
+ )}
+ {usage.storage && (
+
+ )}
+ {usage.video_minutes && (
+
+ )}
+
+ )}
+
+
+
+ {/* Stats grid */}
+
+
+
+ Начало
+
+
+ {subscription.start_date
+ ? new Date(subscription.start_date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
+ : '—'}
+
+
+
+
+ Окончание
+
+
+ {subscription.end_date
+ ? new Date(subscription.end_date).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' })
+ : '—'}
+
+
+
+
+ Период
+
+
+ {getPeriodLabel(plan)}
+
+
+
+
+
+
+
+ {['active', 'trial'].includes(subscription.status) ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function PlanCard({ plan, isCurrent, onSubscribe, subscribing, index }) {
+ const isPerStudent = plan.subscription_type === 'per_student';
+ const features = plan.features || {};
+ const activeFeatures = Object.entries(FEATURE_ICONS).filter(([key]) => features[key]);
+
+ const gradient = PLAN_GRADIENTS[index % PLAN_GRADIENTS.length];
+ const planIcon = PLAN_ICONS[index % PLAN_ICONS.length];
+
+ return (
+
+
+ : isCurrent
+ ?
+ : null
+ }
+ />
+
+
+ {plan.name}
+ {plan.description && (
+
+ {plan.description}
+
+ )}
+
+
+ {plan.trial_days > 0 && (
+
+
+
+ )}
+
+
+ {activeFeatures.map(([key, { label }]) => (
+
+
+ {label}
+
+ ))}
+
+
+ {isPerStudent && plan.bulk_discounts?.length > 0 && (
+
+
+ Прогрессивные скидки:
+
+ {plan.bulk_discounts.map((d, i) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+ {d.min_students}{d.max_students ? `–${d.max_students}` : '+'} уч.
+
+
+ {d.price_per_student} ₽/уч.
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ Ученики
+
+
+ {plan.max_clients != null ? plan.max_clients : '∞'}
+
+
+
+
+ Занятий/мес
+
+
+ {plan.max_lessons_per_month != null ? plan.max_lessons_per_month : '∞'}
+
+
+
+
+ Хранилище
+
+
+ {formatStorage(plan.max_storage_mb)}
+
+
+
+
+
+
+
+
+ {isPerStudent
+ ? <>{plan.price_per_student ?? plan.price} ₽ / ученик>
+ : Number(plan.price) === 0
+ ? Бесплатно
+ : <>{plan.price} ₽ / {getPeriodLabel(plan)}>
+ }
+
+
+
+
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function ConfirmSubscribeDialog({ open, plan, onConfirm, onClose, loading }) {
+ if (!plan) return null;
+ return (
+
+ );
}
// ----------------------------------------------------------------------
export function PaymentPlatformView() {
- const [subscriptions, setSubscriptions] = useState([]);
- const [history, setHistory] = useState([]);
+ const [plans, setPlans] = useState([]);
+ const [activeSub, setActiveSub] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [cancelling, setCancelling] = useState(false);
+ const [subscribingPlan, setSubscribingPlan] = useState(null);
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [subscribing, setSubscribing] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
- const [subs, hist] = await Promise.all([
- getPaymentInfo().catch(() => []),
- getPaymentHistory().catch(() => []),
+ setError(null);
+ const [plansRes, subRes] = await Promise.allSettled([
+ getPlans(),
+ getActiveSubscription(),
]);
- setSubscriptions(subs);
- setHistory(hist);
+ if (plansRes.status === 'fulfilled') setPlans(plansRes.value);
+ if (subRes.status === 'fulfilled') setActiveSub(subRes.value);
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
} finally {
@@ -78,9 +613,46 @@ export function PaymentPlatformView() {
}
}, []);
- useEffect(() => {
- load();
- }, [load]);
+ useEffect(() => { load(); }, [load]);
+
+ const handleCancel = async (id) => {
+ try {
+ setCancelling(true);
+ setError(null);
+ await cancelSubscription(id);
+ setSuccess('Подписка отменена');
+ await load();
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка отмены');
+ } finally {
+ setCancelling(false);
+ }
+ };
+
+ const handleSubscribeClick = (plan) => {
+ setSubscribingPlan(plan);
+ setConfirmOpen(true);
+ };
+
+ const handleSubscribeConfirm = async () => {
+ if (!subscribingPlan) return;
+ try {
+ setSubscribing(true);
+ setError(null);
+ await createSubscription(subscribingPlan.id, subscribingPlan.duration_days ?? null);
+ setSuccess(`Тариф «${subscribingPlan.name}» подключён`);
+ setConfirmOpen(false);
+ setSubscribingPlan(null);
+ await load();
+ } catch (e) {
+ setError(e?.response?.data?.detail || e?.message || 'Ошибка подключения');
+ } finally {
+ setSubscribing(false);
+ }
+ };
+
+ // Build the full card list: active subscription first (if exists), then plans
+ const totalCols = 3;
return (
@@ -90,116 +662,66 @@ export function PaymentPlatformView() {
sx={{ mb: 3 }}
/>
- {error && (
-
- {error}
-
- )}
+ {error && setError(null)}>{error}}
+ {success && setSuccess(null)}>{success}}
{loading ? (
) : (
-
- {/* Subscriptions */}
- {subscriptions.length > 0 ? (
-
-
-
- Активные подписки
-
-
- {subscriptions.map((sub, idx) => (
- // eslint-disable-next-line react/no-array-index-key
-
- {idx > 0 && }
-
-
-
- {sub.plan_name || sub.name || 'Подписка'}
-
- {sub.expires_at && (
-
- До {formatDate(sub.expires_at)}
-
- )}
-
-
- {sub.status && (
-
- )}
- {sub.price != null && (
-
- {formatAmount(sub.price, sub.currency)}
-
- )}
-
-
-
- ))}
-
-
-
+
+ {/* Active subscription — always 1 column */}
+ {activeSub ? (
+
) : (
-
-
-
-
-
- Нет активных подписок
-
- }>
- Подключить подписку
-
-
+
+
+
+ Нет подписки
+
+ Выберите тариф справа
+
)}
- {/* Payment history */}
- {history.length > 0 && (
-
-
-
- История платежей
-
-
- {history.map((item, idx) => (
- // eslint-disable-next-line react/no-array-index-key
-
-
-
- {item.description || item.plan_name || 'Платёж'}
-
-
- {formatDate(item.created_at || item.date)}
-
-
-
-
- {formatAmount(item.amount, item.currency)}
-
- {item.status && (
-
- )}
-
-
- ))}
-
-
-
- )}
-
+ {/* Plan cards */}
+ {plans.map((plan, index) => (
+
+ ))}
+
)}
+
+ { setConfirmOpen(false); setSubscribingPlan(null); }}
+ loading={subscribing}
+ />
);
}
diff --git a/front_minimal/src/sections/referrals/view/referrals-view.jsx b/front_minimal/src/sections/referrals/view/referrals-view.jsx
index cc7869e..a53c3b0 100644
--- a/front_minimal/src/sections/referrals/view/referrals-view.jsx
+++ b/front_minimal/src/sections/referrals/view/referrals-view.jsx
@@ -2,139 +2,154 @@
import { useState, useEffect, useCallback } from 'react';
+import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert';
+import Table from '@mui/material/Table';
+import Tabs from '@mui/material/Tabs';
+import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import Tooltip from '@mui/material/Tooltip';
+import TableRow from '@mui/material/TableRow';
+import TableBody from '@mui/material/TableBody';
+import TableCell from '@mui/material/TableCell';
+import TableHead from '@mui/material/TableHead';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import CardContent from '@mui/material/CardContent';
+import LinearProgress from '@mui/material/LinearProgress';
import InputAdornment from '@mui/material/InputAdornment';
import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths';
-import { getMyReferrals, getReferralStats, getReferralProfile } from 'src/utils/referrals-api';
+import {
+ getReferralStats,
+ getReferralLevels,
+ getMyReferrals,
+ getBonusBalance,
+ getBonusTransactions,
+ getReferralEarnings,
+} from 'src/utils/referrals-api';
-import { DashboardContent } from 'src/layouts/dashboard';
+import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
// ----------------------------------------------------------------------
-function StatCard({ label, value, icon, color }) {
+function StatCard({ label, value, icon, color = 'primary' }) {
return (
-
+
-
-
-
+
+
+
-
- {value ?? '—'}
-
+ {value ?? '—'}
-
- {label}
-
+ {label}
);
}
-function ReferralTable({ title, items }) {
- if (!items || items.length === 0) return null;
+function CopyField({ label, value }) {
+ const [copied, setCopied] = useState(false);
+ const handleCopy = () => {
+ navigator.clipboard.writeText(value).then(() => {
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ });
+ };
return (
-
-
- {title}
-
-
- {items.map((item, idx) => (
- // eslint-disable-next-line react/no-array-index-key
-
- {item.email}
-
-
-
- {item.total_points} pts
-
-
-
- ))}
-
-
+
+
+
+
+
+
+
+ ),
+ }}
+ />
);
}
// ----------------------------------------------------------------------
export function ReferralsView() {
- const [profile, setProfile] = useState(null);
+ const [tab, setTab] = useState(0);
const [stats, setStats] = useState(null);
+ const [levels, setLevels] = useState([]);
const [referrals, setReferrals] = useState(null);
+ const [bonusBalance, setBonusBalance] = useState(null);
+ const [bonusTxns, setBonusTxns] = useState([]);
+ const [earnings, setEarnings] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
- const [copied, setCopied] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
- const [p, s, r] = await Promise.all([
- getReferralProfile(),
+ const [s, l, r, b, bt, e] = await Promise.all([
getReferralStats(),
- getMyReferrals().catch(() => null),
+ getReferralLevels(),
+ getMyReferrals(),
+ getBonusBalance(),
+ getBonusTransactions(),
+ getReferralEarnings(),
]);
- setProfile(p);
setStats(s);
+ setLevels(Array.isArray(l) ? l : []);
setReferrals(r);
- } catch (e) {
- setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
+ setBonusBalance(b);
+ setBonusTxns(Array.isArray(bt) ? bt : []);
+ setEarnings(Array.isArray(e) ? e : []);
+ } catch (err) {
+ setError(err?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
}, []);
- useEffect(() => {
- load();
- }, [load]);
+ useEffect(() => { load(); }, [load]);
- const handleCopyLink = () => {
- const link = profile?.referral_link || stats?.referral_code || '';
- if (!link) return;
- navigator.clipboard.writeText(link).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
- };
+ const referralCode = stats?.referral_code || '';
+ const referralLink = referralCode
+ ? `${window.location.origin}/auth/jwt/sign-up?ref=${referralCode}`
+ : '';
- const handleCopyCode = () => {
- const code = profile?.referral_code || stats?.referral_code || '';
- if (!code) return;
- navigator.clipboard.writeText(code).then(() => {
- setCopied(true);
- setTimeout(() => setCopied(false), 2000);
- });
- };
+ const currentLevel = stats?.current_level;
+ const nextLevelObj = levels.find((l) => l.level === (currentLevel?.level ?? 0) + 1);
+ const progressPct = nextLevelObj
+ ? Math.min(Math.round(((stats?.total_points ?? 0) / nextLevelObj.points_required) * 100), 100)
+ : 100;
- const referralCode = profile?.referral_code || stats?.referral_code || '';
- const referralLink = profile?.referral_link || '';
+ if (loading) return (
+
+
+
+ );
return (
@@ -144,155 +159,290 @@ export function ReferralsView() {
sx={{ mb: 3 }}
/>
- {error && (
-
- {error}
-
- )}
+ {error && {error}}
- {loading ? (
-
-
-
- ) : (
-
- {/* Stats */}
- {stats && (
-
-
-
-
-
-
-
- )}
+
- {/* Level */}
- {stats?.current_level && (
-
-
-
-
-
-
- Уровень {stats.current_level.level} — {stats.current_level.name}
-
-
- Ваш текущий реферальный уровень
-
-
-
-
-
- )}
-
- {/* Referral code & link */}
- {referralCode && (
-
-
-
- Ваш реферальный код
-
-
-
-
-
-
-
-
-
- ),
- }}
- fullWidth
- size="small"
- />
- {referralLink && (
-
-
-
-
-
-
-
- ),
- }}
- fullWidth
- size="small"
- />
- )}
- }
- onClick={handleCopyLink || handleCopyCode}
- sx={{ alignSelf: 'flex-start' }}
- >
- Поделиться
-
-
-
-
- )}
-
- {/* Referrals list */}
- {referrals && (referrals.direct?.length > 0 || referrals.indirect?.length > 0) && (
-
-
-
- Мои рефералы
-
-
-
- {referrals.direct?.length > 0 && referrals.indirect?.length > 0 && }
-
-
-
-
- )}
-
- {!referralCode && !loading && (
-
- Реферальная программа недоступна
-
- )}
+ {/* Stats */}
+
+
+
+
+
+
- )}
+
+ {/* Level progress */}
+ {currentLevel && (
+
+
+
+
+
+
+
+
+ Уровень {currentLevel.level} — {currentLevel.name}
+
+
+ {stats?.total_points ?? 0} очков
+ {nextLevelObj ? ` / ${nextLevelObj.points_required} до следующего уровня` : ' — максимальный уровень'}
+
+
+ {currentLevel.bonus_payment_percent > 0 && (
+
+ )}
+
+ {nextLevelObj && (
+
+ )}
+
+
+ )}
+
+ {/* Referral code & link */}
+ {referralCode && (
+
+
+ Ваш реферальный код
+
+
+
+
+ Поделитесь ссылкой — когда новый пользователь зарегистрируется по ней,
+ вы получите бонусные очки и процент с его платежей.
+
+
+
+
+ )}
+
+ {/* Tabs: Рефералы / Уровни / История */}
+
+ setTab(v)} sx={{ px: 2, pt: 1 }}>
+
+
+
+
+
+
+
+ {/* Рефералы */}
+ {tab === 0 && (
+
+ {(!referrals?.direct?.length && !referrals?.indirect?.length) ? (
+
+
+
+ Пока нет рефералов. Поделитесь ссылкой!
+
+
+ ) : (
+
+ {referrals?.direct?.length > 0 && (
+ <>
+ Прямые ({referrals.direct.length})
+
+ {referrals.direct.map((r, i) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+ {(r.email?.[0] || '?').toUpperCase()}
+
+ {r.email}
+
+ {r.total_points} pts
+
+ ))}
+
+ >
+ )}
+ {referrals?.indirect?.length > 0 && (
+ <>
+ {referrals?.direct?.length > 0 && }
+ Непрямые ({referrals.indirect.length})
+
+ {referrals.indirect.map((r, i) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+ {(r.email?.[0] || '?').toUpperCase()}
+
+ {r.email}
+
+ {r.total_points} pts
+
+ ))}
+
+ >
+ )}
+
+ )}
+
+ )}
+
+ {/* Уровни */}
+ {tab === 1 && (
+
+ {levels.length === 0 ? (
+
+ Информация об уровнях недоступна
+
+ ) : (
+
+ {levels.map((lvl) => {
+ const isActive = currentLevel?.level === lvl.level;
+ return (
+
+
+
+ {lvl.level}
+
+
+
+ {lvl.name}
+
+ От {lvl.points_required} очков
+
+
+ {lvl.bonus_payment_percent > 0 && (
+
+ )}
+ {isActive && }
+
+ );
+ })}
+
+ )}
+
+ )}
+
+ {/* История начислений */}
+ {tab === 2 && (
+
+ {earnings.length === 0 ? (
+
+
+ Нет начислений
+
+ ) : (
+
+
+
+ Дата
+ Реферал
+ Уровень
+ Сумма
+
+
+
+ {earnings.map((e, i) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+
+ {e.created_at ? new Date(e.created_at).toLocaleDateString('ru-RU') : '—'}
+
+
+
+ {e.referred_user_email || '—'}
+
+
+
+
+
+
+ +{e.amount} ₽
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+ {/* Бонусный баланс */}
+ {tab === 3 && (
+
+ {bonusBalance && (
+
+
+
+
+
+ )}
+ {bonusTxns.length === 0 ? (
+
+ Нет транзакций
+
+ ) : (
+
+
+
+ Дата
+ Описание
+ Сумма
+
+
+
+ {bonusTxns.map((t, i) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+
+ {t.created_at ? new Date(t.created_at).toLocaleDateString('ru-RU') : '—'}
+
+
+
+ {t.description || t.transaction_type || '—'}
+
+
+
+ {t.transaction_type === 'earn' ? '+' : '-'}{t.amount} ₽
+
+
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
);
}
diff --git a/front_minimal/src/sections/students/view/students-view.jsx b/front_minimal/src/sections/students/view/students-view.jsx
index 9273d20..bac3a4c 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 { useRef, useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback } from 'react';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';
@@ -37,6 +37,7 @@ import { resolveMediaUrl } from 'src/utils/axios';
import {
getStudents,
getMyMentors,
+ getMyRequests,
getMyInvitations,
addStudentInvitation,
generateInvitationLink,
@@ -46,6 +47,8 @@ import {
rejectInvitationAsStudent,
confirmInvitationAsStudent,
getMentorshipRequestsPending,
+ removeStudent,
+ removeMentor,
} from 'src/utils/students-api';
import { DashboardContent } from 'src/layouts/dashboard';
@@ -63,6 +66,53 @@ function initials(firstName, lastName) {
return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase();
}
+// ----------------------------------------------------------------------
+
+function RemoveConnectionDialog({ open, onClose, onConfirm, name, type, loading }) {
+ return (
+
+ );
+}
+
// ----------------------------------------------------------------------
// MENTOR VIEWS
@@ -72,6 +122,23 @@ function MentorStudentList({ onRefresh }) {
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [error, setError] = useState(null);
+ const [removeTarget, setRemoveTarget] = useState(null); // { id, name }
+ const [removing, setRemoving] = useState(false);
+
+ const handleRemove = async () => {
+ try {
+ setRemoving(true);
+ await removeStudent(removeTarget.id);
+ setRemoveTarget(null);
+ load();
+ onRefresh?.();
+ } catch (e) {
+ setError(e?.response?.data?.error?.message || e?.message || 'Ошибка удаления');
+ setRemoveTarget(null);
+ } finally {
+ setRemoving(false);
+ }
+ };
const load = useCallback(async () => {
try {
@@ -129,12 +196,11 @@ function MentorStudentList({ onRefresh }) {
return (
router.push(paths.dashboard.studentDetail(s.id))}
sx={{
p: 3,
textAlign: 'center',
height: '100%',
- cursor: 'pointer',
+ position: 'relative',
transition: 'box-shadow 0.2s, transform 0.2s',
'&:hover': {
transform: 'translateY(-2px)',
@@ -142,47 +208,49 @@ function MentorStudentList({ onRefresh }) {
},
}}
>
-
- {initials(u.first_name, u.last_name)}
-
+
+ { e.stopPropagation(); setRemoveTarget({ id: s.id, name }); }}
+ sx={{ position: 'absolute', top: 8, right: 8 }}
+ >
+
+
+
-
- {name}
-
-
- {u.email || ''}
-
-
-
- {s.total_lessons != null && (
- }
- />
- )}
- {s.subject && (
-
- )}
-
+ router.push(paths.dashboard.studentDetail(s.id))} sx={{ cursor: 'pointer' }}>
+
+ {initials(u.first_name, u.last_name)}
+
+ {name}
+ {u.email || ''}
+
+ {s.total_lessons != null && (
+ } />
+ )}
+ {s.subject && }
+
+
);
})}
)}
+
+ setRemoveTarget(null)}
+ onConfirm={handleRemove}
+ name={removeTarget?.name || ''}
+ type="student"
+ loading={removing}
+ />
);
}
@@ -279,56 +347,30 @@ 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();
+ const handleChange = (e) => {
+ const v = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
+ onChange(v);
};
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 }}
- />
- ))}
-
+
);
}
@@ -508,14 +550,32 @@ function InviteDialog({ open, onClose, onSuccess }) {
function ClientMentorList() {
const [mentors, setMentors] = useState([]);
const [loading, setLoading] = useState(true);
+ const [removeTarget, setRemoveTarget] = useState(null);
+ const [removing, setRemoving] = useState(false);
- useEffect(() => {
+ const load = useCallback(() => {
+ setLoading(true);
getMyMentors()
.then((list) => setMentors(Array.isArray(list) ? list : []))
.catch(() => setMentors([]))
.finally(() => setLoading(false));
}, []);
+ useEffect(() => { load(); }, [load]);
+
+ const handleRemove = async () => {
+ try {
+ setRemoving(true);
+ await removeMentor(removeTarget.id);
+ setRemoveTarget(null);
+ load();
+ } catch (e) {
+ setRemoveTarget(null);
+ } finally {
+ setRemoving(false);
+ }
+ };
+
if (loading) return ;
if (mentors.length === 0) {
@@ -528,47 +588,59 @@ function ClientMentorList() {
}
return (
-
- {mentors.map((m) => {
- const name = `${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email || '—';
- return (
-
- t.customShadows?.z16 || '0 8px 24px rgba(0,0,0,0.12)',
- },
- }}
- >
-
+
+ {mentors.map((m) => {
+ const name = `${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email || '—';
+ return (
+
+ t.customShadows?.z16 || '0 8px 24px rgba(0,0,0,0.12)',
+ },
}}
>
- {initials(m.first_name, m.last_name)}
-
-
- {name}
-
-
- {m.email || ''}
-
-
-
- );
- })}
-
+
+ setRemoveTarget({ id: m.id, name })}
+ sx={{ position: 'absolute', top: 8, right: 8 }}
+ >
+
+
+
+
+
+ {initials(m.first_name, m.last_name)}
+
+ {name}
+ {m.email || ''}
+
+
+ );
+ })}
+
+
+ setRemoveTarget(null)}
+ onConfirm={handleRemove}
+ name={removeTarget?.name || ''}
+ type="mentor"
+ loading={removing}
+ />
+ >
);
}
@@ -582,7 +654,7 @@ function ClientInvitations({ onRefresh }) {
try {
setLoading(true);
const res = await getMyInvitations();
- setInvitations(Array.isArray(res) ? res.filter((i) => i.status === 'pending') : []);
+ setInvitations(Array.isArray(res) ? res.filter((i) => ['pending', 'pending_student', 'pending_parent'].includes(i.status)) : []);
} catch {
setInvitations([]);
} finally {
@@ -606,8 +678,14 @@ function ClientInvitations({ onRefresh }) {
}
};
- if (loading) return null;
- if (invitations.length === 0) return null;
+ if (loading) return ;
+
+ if (invitations.length === 0) return (
+
+
+ Нет входящих приглашений
+
+ );
return (
@@ -698,6 +776,63 @@ function SendRequestDialog({ open, onClose, onSuccess }) {
);
}
+const STATUS_LABELS = {
+ pending_mentor: { label: 'Ожидает ментора', color: 'warning' },
+ pending_student: { label: 'Ожидает вас', color: 'info' },
+ accepted: { label: 'Принято', color: 'success' },
+ rejected: { label: 'Отклонено', color: 'error' },
+ removed: { label: 'Удалено', color: 'default' },
+};
+
+function ClientOutgoingRequests() {
+ const [requests, setRequests] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ getMyRequests()
+ .then((data) => setRequests(Array.isArray(data) ? data : []))
+ .catch(() => setRequests([]))
+ .finally(() => setLoading(false));
+ }, []);
+
+ if (loading) return ;
+
+ if (requests.length === 0) return (
+
+
+ Нет исходящих заявок
+
+ );
+
+ return (
+
+
+
+
+ {requests.map((req) => {
+ const m = req.mentor || {};
+ const name = `${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email || '—';
+ const statusInfo = STATUS_LABELS[req.status] || { label: req.status, color: 'default' };
+ return (
+
+
+
+ {initials(m.first_name, m.last_name)}
+
+
+
+
+
+ );
+ })}
+
+
+ );
+}
+
// ----------------------------------------------------------------------
export function StudentsView() {
@@ -742,8 +877,14 @@ export function StudentsView() {
>
) : (
<>
-
-
+ setTab(v)} sx={{ mb: 3 }}>
+
+
+
+
+ {tab === 0 && }
+ {tab === 1 && }
+ {tab === 2 && }
setRequestOpen(false)} onSuccess={refresh} />
>
)}
diff --git a/front_minimal/src/sections/video-call/view/video-call-view.jsx b/front_minimal/src/sections/video-call/view/video-call-view.jsx
index 6675b2d..56f7319 100644
--- a/front_minimal/src/sections/video-call/view/video-call-view.jsx
+++ b/front_minimal/src/sections/video-call/view/video-call-view.jsx
@@ -19,11 +19,19 @@ import {
ConnectionStateToast,
} from '@livekit/components-react';
-import { getLesson } from 'src/utils/dashboard-api';
-import { getOrCreateLessonBoard } from 'src/utils/board-api';
-import { getLiveKitConfig, participantConnected } from 'src/utils/livekit-api';
+import { paths } from 'src/routes/paths';
+import { getLesson, completeLesson } from 'src/utils/dashboard-api';
+import { buildExcalidrawSrc, getOrCreateLessonBoard } from 'src/utils/board-api';
+import { getLiveKitConfig, participantConnected, terminateRoom } from 'src/utils/livekit-api';
+import { createChat, normalizeChat } from 'src/utils/chat-api';
-import { ExitLessonModal } from 'src/sections/video-call/livekit/exit-lesson-modal';
+// Payload sent via LiveKit DataChannel to force-disconnect all participants
+const TERMINATE_MSG = JSON.stringify({ type: 'room_terminate' });
+
+import { Iconify } from 'src/components/iconify';
+import { ChatWindow } from 'src/sections/chat/chat-window';
+import { NavMobile } from 'src/layouts/dashboard/nav-mobile';
+import { getNavData } from 'src/layouts/config-nav-dashboard';
import { useAuthContext } from 'src/auth/hooks';
@@ -151,11 +159,16 @@ function StartAudioOverlay() {
};
return (
-
-
Чтобы слышать собеседника, разрешите воспроизведение звука
-
+
+
+
+
+
+
Чтобы слышать собеседника, разрешите воспроизведение звука
+
+
);
}
@@ -167,7 +180,7 @@ function RemoteParticipantPiP({ chatOpen }) {
const remoteRef = tracks.find((ref) => isTrackReference(ref) && ref.participant && !ref.participant.isLocal);
if (!remoteRef || !isTrackReference(remoteRef)) return null;
return (
-
+
);
@@ -175,16 +188,20 @@ function RemoteParticipantPiP({ chatOpen }) {
// ----------------------------------------------------------------------
-function WhiteboardIframe({ boardId, showingBoard }) {
- const excalidrawUrl = process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '';
+function WhiteboardIframe({ boardId, showingBoard, user }) {
const iframeRef = useRef(null);
+ const excalidrawConfigured = !!(
+ process.env.NEXT_PUBLIC_EXCALIDRAW_URL ||
+ process.env.NEXT_PUBLIC_EXCALIDRAW_PATH
+ );
+
useEffect(() => {
- if (!excalidrawUrl || !boardId) return undefined;
+ if (!excalidrawConfigured || !boardId) return undefined;
const container = iframeRef.current;
if (!container) return undefined;
const iframe = document.createElement('iframe');
- iframe.src = `${excalidrawUrl}?boardId=${boardId}`;
+ iframe.src = buildExcalidrawSrc(boardId, user);
iframe.style.cssText = 'width:100%;height:100%;border:none;';
iframe.allow = 'camera; microphone; fullscreen';
container.innerHTML = '';
@@ -192,9 +209,9 @@ function WhiteboardIframe({ boardId, showingBoard }) {
return () => {
container.innerHTML = '';
};
- }, [boardId, excalidrawUrl]);
+ }, [boardId, excalidrawConfigured, user]);
- if (!excalidrawUrl) {
+ if (!excalidrawConfigured) {
return (
Доска не настроена (NEXT_PUBLIC_EXCALIDRAW_URL)
@@ -227,7 +244,7 @@ function PreJoinScreen({ onJoin, onCancel }) {
if (!videoEnabled) return undefined;
let stream = null;
navigator.mediaDevices
- .getUserMedia({ video: { width: { ideal: 2560 }, height: { ideal: 1440 }, frameRate: { ideal: 30 } }, audio: false })
+ .getUserMedia({ video: { width: { ideal: 640 }, height: { ideal: 480 } }, audio: false })
.then((s) => {
stream = s;
if (videoRef.current) videoRef.current.srcObject = s;
@@ -246,42 +263,64 @@ function PreJoinScreen({ onJoin, onCancel }) {
onJoin(audioEnabled, videoEnabled);
};
+ const S = {
+ page: { display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: '#0d0d0d', overflow: 'hidden' },
+ card: { background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.10)', borderRadius: 22, width: '100%', maxWidth: 480, overflow: 'hidden', boxShadow: '0 24px 64px rgba(0,0,0,0.6)' },
+ header: { padding: '24px 28px 20px', borderBottom: '1px solid rgba(255,255,255,0.08)' },
+ title: { fontSize: 20, fontWeight: 700, color: '#fff', margin: 0 },
+ sub: { fontSize: 13, color: 'rgba(255,255,255,0.5)', margin: '4px 0 0' },
+ body: { padding: 24, display: 'flex', flexDirection: 'column', gap: 16 },
+ preview: { background: '#111', borderRadius: 14, aspectRatio: '16/9', overflow: 'hidden', position: 'relative' },
+ previewOff: { position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 10, color: 'rgba(255,255,255,0.35)' },
+ toggleRow: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 16px', background: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.07)', borderRadius: 12 },
+ toggleLabel: { display: 'flex', alignItems: 'center', gap: 10, color: 'rgba(255,255,255,0.85)', fontSize: 14 },
+ toggleBtn: (on) => ({ padding: '7px 18px', borderRadius: 9, border: 'none', background: on ? 'rgba(25,118,210,0.25)' : 'rgba(255,255,255,0.08)', color: on ? '#60a5fa' : 'rgba(255,255,255,0.5)', fontSize: 13, fontWeight: 600, cursor: 'pointer', transition: 'all 0.15s' }),
+ actions: { display: 'flex', gap: 10 },
+ cancelBtn: { flex: 1, padding: '13px', borderRadius: 12, border: '1px solid rgba(255,255,255,0.12)', background: 'transparent', color: 'rgba(255,255,255,0.6)', fontSize: 14, cursor: 'pointer' },
+ joinBtn: { flex: 2, padding: '13px', borderRadius: 12, border: 'none', background: 'linear-gradient(135deg, #1976d2 0%, #1565c0 100%)', color: '#fff', fontSize: 14, fontWeight: 700, cursor: 'pointer', letterSpacing: '0.02em', boxShadow: '0 4px 16px rgba(25,118,210,0.35)' },
+ };
+
+ const devices = [
+ { key: 'mic', label: 'Микрофон', icon: audioEnabled ? 'solar:microphone-bold' : 'solar:microphone-slash-bold', enabled: audioEnabled, toggle: () => setAudioEnabled((v) => !v) },
+ { key: 'cam', label: 'Камера', icon: videoEnabled ? 'solar:camera-bold' : 'solar:camera-slash-bold', enabled: videoEnabled, toggle: () => setVideoEnabled((v) => !v) },
+ ];
+
return (
-
-
-
-
Настройки перед входом
-
Настройте камеру и микрофон
+
+
+
+
Настройки перед входом
+
Проверьте камеру и микрофон
-
-
- {videoEnabled ? (
-
- ) : (
-
- )}
+
+
+ {videoEnabled
+ ?
+ : (
+
+
+ Камера выключена
+
+ )}
-
- {[
- { label: 'Микрофон', enabled: audioEnabled, toggle: () => setAudioEnabled((v) => !v) },
- { label: 'Камера', enabled: videoEnabled, toggle: () => setVideoEnabled((v) => !v) },
- ].map(({ label, enabled, toggle }) => (
-
-
{label}
-
@@ -291,13 +330,113 @@ function PreJoinScreen({ onJoin, onCancel }) {
// ----------------------------------------------------------------------
-function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard }) {
+// Panel width constant already defined at top
+// ----------------------------------------------------------------------
+
+function VideoCallChatPanel({ lesson, currentUser, onClose }) {
+ const [chat, setChat] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ if (!lesson || !currentUser) return;
+ const getOtherUserId = () => {
+ if (currentUser.role === 'mentor') {
+ const client = lesson.client;
+ if (!client) return null;
+ if (typeof client === 'object') return client.user?.id ?? client.id ?? null;
+ return client;
+ }
+ const mentor = lesson.mentor;
+ if (!mentor) return null;
+ if (typeof mentor === 'object') return mentor.id ?? null;
+ return mentor;
+ };
+
+ const otherId = getOtherUserId();
+ if (!otherId) {
+ setError('Не удалось определить собеседника');
+ setLoading(false);
+ return;
+ }
+
+ setLoading(true);
+ createChat(otherId)
+ .then((raw) => {
+ const enriched = { ...raw };
+ if (!enriched.other_participant) {
+ // fallback: determine name from lesson data
+ const other = currentUser.role === 'mentor' ? lesson.client : lesson.mentor;
+ if (other && typeof other === 'object') {
+ const u = other.user || other;
+ enriched.other_participant = {
+ id: otherId,
+ first_name: u.first_name,
+ last_name: u.last_name,
+ avatar_url: u.avatar_url || u.avatar || null,
+ };
+ }
+ }
+ setChat(normalizeChat(enriched));
+ })
+ .catch((e) => setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки чата'))
+ .finally(() => setLoading(false));
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [lesson?.id, currentUser?.id]);
+
+ return (
+
+ {/* Header */}
+
+
+ {loading && (
+
+
+ Загрузка чата…
+
+ )}
+ {error && (
+
+ {error}
+
+ )}
+ {!loading && !error && (
+
+ )}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+function RoomContent({ lessonId, lesson, boardId, boardLoading, showBoard, setShowBoard, postDisconnectRef }) {
const room = useRoomContext();
const router = useRouter();
const { user } = useAuthContext();
const [showPlatformChat, setShowPlatformChat] = useState(false);
- const [showExitModal, setShowExitModal] = useState(false);
+ const [showExitMenu, setShowExitMenu] = useState(false);
const [showNavMenu, setShowNavMenu] = useState(false);
+ const [terminatingAll, setTerminatingAll] = useState(false);
+ const [exitBtnRect, setExitBtnRect] = useState(null);
+ const exitBtnRef = useRef(null);
+ const isMentor = user?.role === 'mentor';
+
+ const handleToggleExitMenu = () => {
+ if (!showExitMenu && exitBtnRef.current) {
+ setExitBtnRect(exitBtnRef.current.getBoundingClientRect());
+ }
+ setShowExitMenu((v) => !v);
+ };
useEffect(() => {
const onConnected = () => {
@@ -335,7 +474,7 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard
btn.className = 'lk-button lk-custom-exit-button';
btn.title = 'Выйти';
btn.textContent = '🚪';
- btn.addEventListener('click', () => window.dispatchEvent(new CustomEvent('livekit-exit-click')));
+ btn.addEventListener('click', (ev) => window.dispatchEvent(new CustomEvent('livekit-exit-click', { detail: { target: ev.currentTarget } })));
bar.appendChild(btn);
}
}, 800);
@@ -349,16 +488,61 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard
}, []);
useEffect(() => {
- const handler = () => {
- if (user?.role === 'mentor') {
- setShowExitModal(true);
- } else {
- room.disconnect();
- }
+ // Control bar button click: capture its rect for popup positioning
+ const handler = (e) => {
+ const btn = e?.detail?.target ?? document.querySelector('.lk-custom-exit-button');
+ if (btn) setExitBtnRect(btn.getBoundingClientRect());
+ setShowExitMenu(true);
};
window.addEventListener('livekit-exit-click', handler);
return () => window.removeEventListener('livekit-exit-click', handler);
- }, [user?.role, room]);
+ }, []);
+
+ const handleJustExit = () => {
+ setShowExitMenu(false);
+ room.disconnect();
+ };
+
+ const handleTerminateForAll = async () => {
+ setShowExitMenu(false);
+ setTerminatingAll(true);
+ if (lessonId != null) {
+ try { sessionStorage.setItem('complete_lesson_id', String(lessonId)); } catch { /* ignore */ }
+ if (postDisconnectRef) postDisconnectRef.current = paths.dashboard.calendar;
+
+ // 1. Broadcast terminate signal via LiveKit DataChannel so clients
+ // disconnect immediately (fallback if backend call is slow/fails).
+ try {
+ await room.localParticipant.publishData(
+ new TextEncoder().encode(TERMINATE_MSG),
+ { reliable: true }
+ );
+ } catch { /* ignore */ }
+
+ // 2. Backend: terminate LiveKit room + mark lesson as completed.
+ try { await terminateRoom(lessonId); } catch { /* ignore */ }
+
+ // 3. Ensure lesson status is "completed" on backend even if
+ // terminateRoom doesn't handle it.
+ try { await completeLesson(String(lessonId), '', undefined, undefined, undefined, false, undefined); } catch { /* ignore */ }
+ }
+ room.disconnect();
+ };
+
+ // Listen for terminate broadcast from mentor (runs on client side).
+ useEffect(() => {
+ if (isMentor) return undefined;
+ const onData = (payload) => {
+ try {
+ const msg = JSON.parse(new TextDecoder().decode(payload));
+ if (msg?.type === 'room_terminate') {
+ room.disconnect();
+ }
+ } catch { /* ignore */ }
+ };
+ room.on(RoomEvent.DataReceived, onData);
+ return () => { room.off(RoomEvent.DataReceived, onData); };
+ }, [room, isMentor]);
// Save audio/video state on track events
useEffect(() => {
@@ -401,103 +585,159 @@ function RoomContent({ lessonId, boardId, boardLoading, showBoard, setShowBoard
};
}, [room]);
- const sidebarStyle = {
- position: 'fixed',
- right: showPlatformChat ? CHAT_PANEL_WIDTH + 16 : 16,
- top: '50%',
- transform: 'translateY(-50%)',
- display: 'flex',
- flexDirection: 'column',
- gap: 8,
- padding: 8,
- background: 'rgba(0,0,0,0.7)',
- borderRadius: 12,
- zIndex: 10001,
- };
+ const sidebarRight = showPlatformChat ? CHAT_PANEL_WIDTH + 12 : 12;
- const iconBtn = (active, disabled, title, icon, onClick) => (
+ const SideBtn = ({ active, disabled: dis, title, icon, onClick: handleClick }) => (
- {icon}
+
);
return (
-
+
-
-
-
- message} />
-
-
+
+
+ message} />
+
{typeof document !== 'undefined' &&
createPortal(
<>
{showBoard &&
}
+
+ {/* Board burger button */}
{showBoard && (
setShowNavMenu((v) => !v)}
- style={{ position: 'fixed', left: 16, bottom: 64, width: 48, height: 48, borderRadius: 12, border: 'none', background: 'rgba(0,0,0,0.7)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002, fontSize: 20 }}
title="Меню"
+ style={{ position: 'fixed', left: 12, bottom: 96, width: 44, height: 44, borderRadius: 12, border: '1px solid rgba(255,255,255,0.10)', background: 'rgba(15,15,15,0.85)', backdropFilter: 'blur(12px)', color: 'rgba(255,255,255,0.7)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 10002 }}
>
- ☰
+
)}
- {showNavMenu && (
- // eslint-disable-next-line jsx-a11y/no-static-element-interactions
-
setShowNavMenu(false)}
- onKeyDown={(e) => e.key === 'Escape' && setShowNavMenu(false)}
- style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)', zIndex: 10003, backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
+
+ {/* Nav menu — existing NavMobile, all links open in new tab */}
+
setShowNavMenu(false)}
+ data={getNavData(user?.role).map((section) => ({
+ ...section,
+ items: section.items.map((item) => ({ ...item, externalLink: true })),
+ }))}
+ sx={{ bgcolor: 'grey.900', width: 300 }}
+ />
+
+ {/* Right sidebar */}
+
+
setShowBoard(false)} />
+ boardId && !boardLoading && setShowBoard(true)}
+ />
+ {lessonId != null && (
+ setShowPlatformChat((v) => !v)} />
+ )}
+ {/* Divider */}
+
+ {/* Exit button */}
+
- {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
- e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} style={{ background: '#fff', borderRadius: 16, padding: 24, minWidth: 240 }}>
- { setShowNavMenu(false); router.push('/dashboard'); }} style={{ width: '100%', padding: '12px 16px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 16, borderRadius: 8 }}>
- 🏠 На главную
-
- setShowNavMenu(false)} style={{ width: '100%', padding: '12px 16px', border: 'none', background: 'none', textAlign: 'left', cursor: 'pointer', fontSize: 16, borderRadius: 8 }}>
- ✕ Закрыть
-
-
-
- )}
-
- {iconBtn(!showBoard, false, 'Камера', '📹', () => setShowBoard(false))}
- {iconBtn(showBoard, !boardId || boardLoading, boardLoading ? 'Загрузка доски...' : !boardId ? 'Доска недоступна' : 'Доска', boardLoading ? '⏳' : '🎨', () => boardId && !boardLoading && setShowBoard(true))}
- {lessonId != null && iconBtn(showPlatformChat, false, 'Чат', '💬', () => setShowPlatformChat((v) => !v))}
+
+
+
+ {/* Exit menu popup — anchored to the exit button rect */}
+ {showExitMenu && exitBtnRect && (
+ <>
+ {/* Backdrop */}
+ setShowExitMenu(false)}
+ />
+ {/* Popup: right edge aligned with exit button right edge, bottom edge at exit button top - 8px */}
+
+
+
+ Выйти
+
+ {isMentor && (
+
+
+ {terminatingAll ? 'Завершение…' : 'Выйти и завершить для всех'}
+
+ )}
+
+ >
+ )}
+
+ {showPlatformChat && lesson && (
+
setShowPlatformChat(false)}
+ />
+ )}
>,
document.body
)}
-
- setShowExitModal(false)}
- onExit={() => room.disconnect()}
- />
);
}
@@ -518,10 +758,23 @@ export function VideoCallView() {
const [avReady, setAvReady] = useState(false);
const [lessonCompleted, setLessonCompleted] = useState(false);
const [effectiveLessonId, setEffectiveLessonId] = useState(null);
+ const [lesson, setLesson] = useState(null);
const [boardId, setBoardId] = useState(null);
const [boardLoading, setBoardLoading] = useState(false);
const [showBoard, setShowBoard] = useState(false);
const boardPollRef = useRef(null);
+ const postDisconnectRef = useRef('/dashboard');
+
+ // Lock scroll while on the video-call page, restore on unmount
+ useEffect(() => {
+ const prev = document.documentElement.style.overflow;
+ document.documentElement.style.overflow = 'hidden';
+ document.body.style.overflow = 'hidden';
+ return () => {
+ document.documentElement.style.overflow = prev;
+ document.body.style.overflow = '';
+ };
+ }, []);
// Load audio/video preferences from localStorage after mount
useEffect(() => {
@@ -553,6 +806,7 @@ export function VideoCallView() {
if (lessonIdParam) {
try {
const l = await getLesson(lessonIdParam);
+ setLesson(l);
if (l.status === 'completed') {
const now = new Date();
const end = l.completed_at ? new Date(l.completed_at) : new Date(l.end_time);
@@ -668,7 +922,7 @@ export function VideoCallView() {
key="board-layer"
style={{ position: 'fixed', inset: 0, zIndex: showBoard ? 9999 : 0, pointerEvents: showBoard ? 'auto' : 'none' }}
>
-
+
)}
router.push('/dashboard')}
+ onDisconnected={() => router.push(postDisconnectRef.current)}
style={{ height: '100vh' }}
data-lk-theme="default"
options={{
@@ -697,10 +951,12 @@ export function VideoCallView() {
>
diff --git a/front_minimal/src/styles/livekit-theme.css b/front_minimal/src/styles/livekit-theme.css
index 334162f..217c794 100644
--- a/front_minimal/src/styles/livekit-theme.css
+++ b/front_minimal/src/styles/livekit-theme.css
@@ -1,429 +1,369 @@
/**
- * Кастомизация LiveKit через CSS переменные.
- * Все стили и скрипты LiveKit отдаются с нашего сервера (бандл + этот файл).
+ * LiveKit custom theme — platform edition
*/
-@keyframes lk-spin {
- to { transform: rotate(360deg); }
-}
+/* ─── No scroll on video-call page (applied via useEffect in VideoCallView) ── */
+/* ─── CSS Variables ─────────────────────────────────────────────────────────── */
:root {
- /* Цвета фона */
- --lk-bg: #1a1a1a;
- --lk-bg2: #2a2a2a;
- --lk-bg3: #3a3a3a;
-
- /* Цвета текста */
- --lk-fg: #ffffff;
- --lk-fg2: rgba(255, 255, 255, 0.7);
-
- /* Основные цвета */
- --lk-control-bg: var(--md-sys-color-primary);
- --lk-control-hover-bg: var(--md-sys-color-primary-container);
- --lk-button-bg: rgba(255, 255, 255, 0.15);
- --lk-button-hover-bg: rgba(255, 255, 255, 0.25);
-
- /* Границы */
- --lk-border-color: rgba(255, 255, 255, 0.1);
- --lk-border-radius: 12px;
-
- /* Фокус */
- --lk-focus-ring: var(--md-sys-color-primary);
-
- /* Ошибки */
- --lk-danger: var(--md-sys-color-error);
-
- /* Размеры */
- --lk-control-bar-height: 80px;
- --lk-participant-tile-gap: 12px;
+ --vc-bg: #0d0d0d;
+ --vc-surface: rgba(255, 255, 255, 0.06);
+ --vc-surface-hover: rgba(255, 255, 255, 0.12);
+ --vc-border: rgba(255, 255, 255, 0.10);
+ --vc-accent: #1976d2;
+ --vc-accent-hover: #1565c0;
+ --vc-danger: #ef5350;
+ --vc-text: #ffffff;
+ --vc-text-dim: rgba(255, 255, 255, 0.55);
+ --vc-radius: 14px;
+ --vc-blur: blur(18px);
+
+ /* LiveKit CSS vars */
+ --lk-bg: #0d0d0d;
+ --lk-bg2: #1a1a1a;
+ --lk-bg3: #262626;
+ --lk-fg: #ffffff;
+ --lk-fg2: rgba(255, 255, 255, 0.7);
+ --lk-control-bg: rgba(255, 255, 255, 0.08);
+ --lk-control-hover-bg: rgba(255, 255, 255, 0.16);
+ --lk-button-bg: rgba(255, 255, 255, 0.08);
+ --lk-border-color: rgba(255, 255, 255, 0.10);
+ --lk-border-radius: 12px;
+ --lk-danger: #ef5350;
+ --lk-accent-bg: #1976d2;
+ --lk-accent-fg: #ffffff;
+ --lk-control-bar-height: 76px;
+ --lk-grid-gap: 10px;
}
-/* Панель управления — без ограничения по ширине */
+/* ─── Room root ─────────────────────────────────────────────────────────────── */
+.lk-room-container {
+ background: var(--vc-bg) !important;
+ overflow: hidden !important;
+}
+
+/* ─── Control bar ───────────────────────────────────────────────────────────── */
.lk-control-bar {
- background: rgba(0, 0, 0, 0.8) !important;
- backdrop-filter: blur(20px) !important;
- border-radius: 16px !important;
- padding: 12px 16px !important;
- margin: 16px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
- max-width: none !important;
+ position: fixed !important;
+ bottom: 20px !important;
+ left: 50% !important;
+ transform: translateX(-50%) !important;
width: auto !important;
+ max-width: calc(100vw - 40px) !important;
+ background: rgba(15, 15, 15, 0.85) !important;
+ backdrop-filter: var(--vc-blur) !important;
+ -webkit-backdrop-filter: var(--vc-blur) !important;
+ border: 1px solid var(--vc-border) !important;
+ border-radius: 20px !important;
+ padding: 10px 16px !important;
+ gap: 6px !important;
+ box-shadow: 0 8px 40px rgba(0, 0, 0, 0.6), 0 2px 8px rgba(0, 0, 0, 0.4) !important;
+ z-index: 9000 !important;
}
.lk-control-bar .lk-button-group,
.lk-control-bar .lk-button-group-menu {
max-width: none !important;
- width: auto !important;
}
-/* Кнопки управления — ширина по контенту, без жёсткого ограничения */
-.lk-control-bar .lk-button {
- min-width: 48px !important;
- width: auto !important;
- height: 48px !important;
+/* ─── All buttons ───────────────────────────────────────────────────────────── */
+.lk-button,
+.lk-start-audio-button,
+.lk-chat-toggle,
+.lk-disconnect-button {
+ min-width: 46px !important;
+ height: 46px !important;
border-radius: 12px !important;
- transition: all 0.2s ease !important;
- padding-left: 12px !important;
- padding-right: 12px !important;
+ transition: background 0.18s ease, transform 0.12s ease !important;
+ padding: 0 14px !important;
+ font-size: 13px !important;
+ font-weight: 500 !important;
+ letter-spacing: 0.01em !important;
+ gap: 7px !important;
}
-/* Русские подписи: скрываем английский текст, показываем свой */
-.lk-control-bar .lk-button[data-lk-source="microphone"],
-.lk-control-bar .lk-button[data-lk-source="camera"],
-.lk-control-bar .lk-button[data-lk-source="screen_share"],
+.lk-button:not(:disabled):hover {
+ transform: translateY(-1px) !important;
+ background: var(--vc-surface-hover) !important;
+}
+
+.lk-button:active { transform: scale(0.96) !important; }
+
+/* Active (enabled) state */
+.lk-button[data-lk-enabled="true"] {
+ background: rgba(25, 118, 210, 0.25) !important;
+ color: #60a5fa !important;
+}
+.lk-button[data-lk-enabled="true"]:hover {
+ background: rgba(25, 118, 210, 0.35) !important;
+}
+
+/* Screen share active */
+.lk-button[data-lk-source="screen_share"][data-lk-enabled="true"] {
+ background: rgba(25, 118, 210, 0.3) !important;
+ color: #60a5fa !important;
+}
+
+/* ─── Russian labels ────────────────────────────────────────────────────────── */
+.lk-control-bar .lk-button[data-lk-source],
.lk-control-bar .lk-chat-toggle,
.lk-control-bar .lk-disconnect-button,
.lk-control-bar .lk-start-audio-button {
font-size: 0 !important;
}
-.lk-control-bar .lk-button[data-lk-source="microphone"] > svg,
-.lk-control-bar .lk-button[data-lk-source="camera"] > svg,
-.lk-control-bar .lk-button[data-lk-source="screen_share"] > svg,
+.lk-control-bar .lk-button > svg,
.lk-control-bar .lk-chat-toggle > svg,
.lk-control-bar .lk-disconnect-button > svg {
- width: 16px !important;
- height: 16px !important;
+ width: 18px !important;
+ height: 18px !important;
flex-shrink: 0 !important;
}
-.lk-control-bar .lk-button[data-lk-source="microphone"]::after {
- content: "Микрофон";
- font-size: 1rem;
-}
+.lk-control-bar .lk-button[data-lk-source="microphone"]::after { content: "Микрофон"; font-size: 13px; }
+.lk-control-bar .lk-button[data-lk-source="camera"]::after { content: "Камера"; font-size: 13px; }
+.lk-control-bar .lk-button[data-lk-source="screen_share"]::after { content: "Экран"; font-size: 13px; }
+.lk-control-bar .lk-button[data-lk-source="screen_share"][data-lk-enabled="true"]::after { content: "Стоп"; }
+.lk-control-bar .lk-chat-toggle::after { content: "Чат"; font-size: 13px; }
-.lk-control-bar .lk-button[data-lk-source="camera"]::after {
- content: "Камера";
- font-size: 1rem;
-}
-
-.lk-control-bar .lk-button[data-lk-source="screen_share"]::after {
- content: "Поделиться экраном";
- font-size: 1rem;
-}
-
-.lk-control-bar .lk-button[data-lk-source="screen_share"][data-lk-enabled="true"]::after {
- content: "Остановить демонстрацию";
-}
-
-.lk-control-bar .lk-chat-toggle::after {
- content: "Чат";
- font-size: 1rem;
-}
-
-/* Кнопка бургер слева от микрофона — в панели LiveKit */
+/* ─── Burger & custom exit (injected via JS) ────────────────────────────────── */
.lk-burger-button {
- background: rgba(255, 255, 255, 0.15) !important;
- color: #fff !important;
+ background: var(--vc-surface) !important;
+ color: var(--vc-text) !important;
+ border-radius: 12px !important;
+}
+.lk-burger-button:hover {
+ background: var(--vc-surface-hover) !important;
}
-/* Скрываем стандартную кнопку «Выйти» — используем свою внутри панели (модалка: Выйти / Выйти и завершить занятие) */
-.lk-control-bar .lk-disconnect-button {
- display: none !important;
-}
-.lk-control-bar .lk-disconnect-button::after {
- content: "Выйти";
- font-size: 1rem;
-}
+/* Hide default disconnect — we use our own */
+.lk-control-bar .lk-disconnect-button { display: none !important; }
-/* Наша кнопка «Выйти» — внутри панели, рядом с «Поделиться экраном» */
+/* Our exit button */
.lk-control-bar .lk-custom-exit-button {
font-size: 0 !important;
- background: var(--md-sys-color-error) !important;
- color: #fff !important;
- border: none;
- cursor: pointer;
+ background: rgba(239, 83, 80, 0.18) !important;
+ color: #fc8181 !important;
+ border: 1px solid rgba(239, 83, 80, 0.30) !important;
+ border-radius: 12px !important;
+ cursor: pointer !important;
display: inline-flex !important;
- align-items: center;
- justify-content: center;
+ align-items: center !important;
+ justify-content: center !important;
+ gap: 7px !important;
+ padding: 0 14px !important;
+ min-width: 46px !important;
+ height: 46px !important;
+ transition: background 0.18s ease !important;
}
.lk-control-bar .lk-custom-exit-button::after {
content: "Выйти";
- font-size: 1rem;
+ font-size: 13px;
+ font-weight: 500;
}
-.lk-control-bar .lk-custom-exit-button > .material-symbols-outlined {
- color: #fff !important;
+.lk-control-bar .lk-custom-exit-button:hover {
+ background: rgba(239, 83, 80, 0.30) !important;
}
-/* Скрываем кнопку «Начать видео» — у нас свой StartAudioOverlay */
-.lk-control-bar .lk-start-audio-button {
- display: none !important;
-}
+/* Hide audio start button (we handle it ourselves) */
+.lk-control-bar .lk-start-audio-button { display: none !important; }
+/* Hide LiveKit chat toggle (we use our own) */
+.lk-control-bar .lk-chat-toggle { display: none !important; }
-/* Кнопки без текста (только иконка) — минимальный размер */
-.lk-button {
- min-width: 48px !important;
- width: auto !important;
- height: 48px !important;
- border-radius: 12px !important;
- transition: all 0.2s ease !important;
-}
-
-.lk-button:hover {
- transform: scale(1.05);
-}
-
-.lk-button:active {
- transform: scale(0.95);
-}
-
-/* Активная кнопка */
-.lk-button[data-lk-enabled="true"] {
- background: var(--md-sys-color-primary) !important;
-}
-
-/* Кнопка отключения — белые иконка и текст */
-.lk-disconnect-button {
- background: var(--md-sys-color-error) !important;
- color: #fff !important;
-}
-.lk-disconnect-button > svg {
- color: #fff !important;
- fill: currentColor;
-}
-
-/* Плитки участников */
+/* ─── Participant tiles ──────────────────────────────────────────────────────── */
.lk-participant-tile {
- border-radius: 12px !important;
+ border-radius: 14px !important;
overflow: hidden !important;
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2) !important;
+ background: #111 !important;
}
-/* Плейсхолдер без камеры: скрываем дефолтную SVG, показываем аватар из API */
-.lk-participant-tile .lk-participant-placeholder svg {
- display: none !important;
+.lk-participant-tile[data-lk-speaking="true"]:not([data-lk-source="screen_share"])::after {
+ border-width: 2px !important;
+ border-color: #60a5fa !important;
+ transition-delay: 0s !important;
+ transition-duration: 0.15s !important;
}
-/* Контейнер для аватара — нужен для container queries */
.lk-participant-tile .lk-participant-placeholder {
- container-type: size;
+ background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%) !important;
}
-
+.lk-participant-tile .lk-participant-placeholder svg { display: none !important; }
+.lk-participant-tile .lk-participant-placeholder { container-type: size; }
.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
- /* Квадрат: меньшая сторона контейнера, максимум 400px */
- --avatar-size: min(min(80cqw, 80cqh), 400px);
+ --avatar-size: min(min(70cqw, 70cqh), 360px);
width: var(--avatar-size);
height: var(--avatar-size);
- aspect-ratio: 1 / 1;
- object-fit: cover;
- object-position: center;
border-radius: 50%;
+ object-fit: cover;
+ box-shadow: 0 0 0 3px rgba(255,255,255,0.12);
}
-
-/* Fallback для браузеров без container queries */
@supports not (width: 1cqw) {
.lk-participant-tile .lk-participant-placeholder img.lk-participant-avatar-img {
- width: 200px;
- height: 200px;
+ width: 180px; height: 180px;
}
}
-/* Имя участника — белый текст (Камера, PiP) */
-.lk-participant-name {
- background: rgba(0, 0, 0, 0.7) !important;
- backdrop-filter: blur(10px) !important;
+/* Participant name badge */
+.lk-participant-metadata { bottom: 10px !important; left: 10px !important; right: 10px !important; }
+.lk-participant-metadata-item {
+ background: rgba(0, 0, 0, 0.55) !important;
+ backdrop-filter: blur(8px) !important;
border-radius: 8px !important;
- padding: 6px 12px !important;
+ padding: 4px 10px !important;
+}
+.lk-participant-name {
+ font-size: 12px !important;
font-weight: 600 !important;
color: #fff !important;
+ letter-spacing: 0.02em !important;
}
-/* Чат LiveKit скрыт — используем чат сервиса (платформы) */
-.lk-video-conference .lk-chat {
- display: none !important;
-}
-
-.lk-control-bar .lk-chat-toggle {
- display: none !important;
-}
-
-/* Стили чата платформы оставляем для других страниц */
-.lk-chat {
- background: var(--md-sys-color-surface) !important;
- border-left: 1px solid var(--md-sys-color-outline) !important;
-}
-
-.lk-chat-entry {
- background: var(--md-sys-color-surface-container) !important;
- border-radius: 12px !important;
- padding: 12px !important;
- margin-bottom: 12px !important;
-}
-
-/* Сетка участников */
+/* ─── Video layouts ──────────────────────────────────────────────────────────── */
.lk-grid-layout {
- gap: 12px !important;
- padding: 12px !important;
+ gap: var(--lk-grid-gap) !important;
+ padding: var(--lk-grid-gap) !important;
+ min-height: 0 !important;
}
+.lk-grid-layout .lk-participant-tile { min-height: 200px; }
-/* Меню выбора устройств — без ограничения по ширине */
-.lk-device-menu,
-.lk-media-device-select {
- max-width: none !important;
- width: max-content !important;
- min-width: 0 !important;
-}
-
-.lk-media-device-select {
- background: rgba(0, 0, 0, 0.95) !important;
- backdrop-filter: blur(20px) !important;
- border-radius: 12px !important;
- padding: 8px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4) !important;
- border: 1px solid rgba(255, 255, 255, 0.1) !important;
-}
-
-.lk-media-device-select button {
- border-radius: 8px !important;
- padding: 10px 14px !important;
- transition: background 0.2s ease !important;
- width: 100% !important;
- min-width: 0 !important;
- white-space: normal !important;
- text-align: left !important;
-}
-
-.lk-media-device-select button:hover {
- background: rgba(255, 255, 255, 0.1) !important;
-}
-
-.lk-media-device-select button[data-lk-active="true"] {
- background: var(--md-sys-color-primary) !important;
-}
-
-/* Индикатор говорящего */
-.lk-participant-tile[data-lk-speaking="true"] {
- box-shadow: 0 0 0 3px var(--md-sys-color-primary) !important;
-}
-
-/* Layout для 1-на-1: собеседник на весь экран, своя камера в углу */
-/* Карусель position:absolute выходит из flow — остаётся только основной контент. */
-/* Сетка 5fr 1fr: единственный grid-ребёнок (основное видео) получает 5fr (расширяется). */
+/* Focus layout: remote fills screen, local in PiP */
.lk-focus-layout {
position: relative !important;
grid-template-columns: 5fr 1fr !important;
}
-
-/* Основное видео (собеседник) на весь экран */
.lk-focus-layout .lk-focus-layout-wrapper {
width: 100% !important;
height: 100% !important;
}
-
.lk-focus-layout .lk-focus-layout-wrapper .lk-participant-tile {
width: 100% !important;
height: 100% !important;
border-radius: 0 !important;
}
-/* Демонстрация экрана — на весь экран только в режиме фокуса (после клика на раскрытие) */
-/* Структура: .lk-focus-layout-wrapper > .lk-focus-layout > .lk-participant-tile */
-.lk-focus-layout > .lk-participant-tile[data-lk-source="screen_share"] {
- position: absolute !important;
- width: 100% !important;
- height: 100% !important;
- top: 0 !important;
- left: 0 !important;
- border-radius: 0 !important;
- z-index: 50 !important;
-}
-
-/* Карусель с локальным видео (своя камера) */
+/* Carousel (local camera) — bottom-right PiP */
.lk-focus-layout .lk-carousel {
position: absolute !important;
- bottom: 80px !important;
+ bottom: 96px !important;
right: 16px !important;
- width: 280px !important;
+ width: 240px !important;
height: auto !important;
z-index: 100 !important;
pointer-events: auto !important;
}
-
.lk-focus-layout .lk-carousel .lk-participant-tile {
- width: 280px !important;
- height: 158px !important;
+ width: 240px !important;
+ height: 135px !important;
border-radius: 12px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
- border: 2px solid rgba(255, 255, 255, 0.2) !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.10) !important;
}
-
-/* Скрыть стрелки карусели (они не нужны для 1 участника) */
.lk-focus-layout .lk-carousel button[aria-label*="Previous"],
-.lk-focus-layout .lk-carousel button[aria-label*="Next"] {
- display: none !important;
-}
+.lk-focus-layout .lk-carousel button[aria-label*="Next"] { display: none !important; }
-/* Если используется grid layout (фоллбэк) */
-.lk-grid-layout {
- position: relative !important;
-}
-
-/* Для 2 участников: первый на весь экран, второй в углу */
+/* Grid 2-person: first full, second PiP */
.lk-grid-layout[data-lk-participants="2"] {
display: block !important;
position: relative !important;
}
-
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:first-child {
position: absolute !important;
- top: 0 !important;
- left: 0 !important;
+ inset: 0 !important;
width: 100% !important;
height: 100% !important;
border-radius: 0 !important;
}
-
.lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
position: absolute !important;
- bottom: 80px !important;
+ bottom: 96px !important;
right: 16px !important;
- width: 280px !important;
- height: 158px !important;
+ width: 240px !important;
+ height: 135px !important;
border-radius: 12px !important;
- box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
- border: 2px solid rgba(255, 255, 255, 0.2) !important;
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255,255,255,0.10) !important;
z-index: 100 !important;
}
-/* Адаптивность */
-@media (max-width: 768px) {
- .lk-control-bar {
- border-radius: 12px !important;
- padding: 8px 12px !important;
- }
-
- .lk-control-bar .lk-button,
- .lk-button {
- min-width: 44px !important;
- width: auto !important;
- height: 44px !important;
- }
-
- /* Уменьшаем размер локального видео на мобильных */
- .lk-focus-layout .lk-carousel,
- .lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
- width: 160px !important;
- height: 90px !important;
- bottom: 70px !important;
- right: 12px !important;
- }
-}
-
-/* Качество отображения видео в контейнере LiveKit */
-.lk-participant-media-video {
- background: #000 !important;
-}
-/* Демонстрация экрана: contain чтобы не обрезать, чёткое отображение */
+/* Screen share */
+.lk-participant-media-video { background: #000 !important; }
.lk-participant-media-video[data-lk-source="screen_share"] {
object-fit: contain !important;
- object-position: center !important;
image-rendering: -webkit-optimize-contrast;
image-rendering: crisp-edges;
}
-/* Сетка: минимальная высота плиток для крупного видео */
-.lk-grid-layout {
- min-height: 0;
+.lk-focus-layout > .lk-participant-tile[data-lk-source="screen_share"] {
+ position: absolute !important;
+ inset: 0 !important;
+ border-radius: 0 !important;
+ z-index: 50 !important;
}
-.lk-grid-layout .lk-participant-tile {
- min-height: 240px;
+
+/* ─── Device menu ───────────────────────────────────────────────────────────── */
+.lk-device-menu,
+.lk-media-device-select {
+ max-width: none !important;
+ width: max-content !important;
+}
+.lk-media-device-select {
+ background: rgba(18, 18, 18, 0.97) !important;
+ backdrop-filter: var(--vc-blur) !important;
+ border-radius: 14px !important;
+ padding: 8px !important;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.6) !important;
+ border: 1px solid var(--vc-border) !important;
+}
+.lk-media-device-select button {
+ border-radius: 9px !important;
+ padding: 9px 14px !important;
+ transition: background 0.15s ease !important;
+ width: 100% !important;
+ text-align: left !important;
+ white-space: nowrap !important;
+ font-size: 13px !important;
+}
+.lk-media-device-select button:hover {
+ background: rgba(255, 255, 255, 0.09) !important;
+}
+.lk-media-device-select [data-lk-active="true"] > .lk-button {
+ background: rgba(25, 118, 210, 0.25) !important;
+ color: #60a5fa !important;
+}
+
+/* ─── Toast ─────────────────────────────────────────────────────────────────── */
+.lk-toast {
+ background: rgba(18, 18, 18, 0.92) !important;
+ backdrop-filter: var(--vc-blur) !important;
+ border: 1px solid var(--vc-border) !important;
+ border-radius: 12px !important;
+ font-size: 13px !important;
+}
+
+/* ─── Hide LiveKit built-in chat ────────────────────────────────────────────── */
+.lk-video-conference .lk-chat { display: none !important; }
+
+/* ─── Mobile ────────────────────────────────────────────────────────────────── */
+@media (max-width: 768px) {
+ .lk-control-bar {
+ border-radius: 16px !important;
+ padding: 8px 12px !important;
+ bottom: 12px !important;
+ }
+ .lk-button,
+ .lk-start-audio-button,
+ .lk-chat-toggle,
+ .lk-disconnect-button {
+ min-width: 42px !important;
+ height: 42px !important;
+ padding: 0 10px !important;
+ }
+ .lk-control-bar .lk-button[data-lk-source]::after,
+ .lk-control-bar .lk-chat-toggle::after,
+ .lk-control-bar .lk-custom-exit-button::after { display: none; }
+
+ .lk-focus-layout .lk-carousel,
+ .lk-grid-layout[data-lk-participants="2"] .lk-participant-tile:last-child {
+ width: 140px !important;
+ height: 79px !important;
+ bottom: 76px !important;
+ right: 12px !important;
+ }
}
diff --git a/front_minimal/src/utils/axios.js b/front_minimal/src/utils/axios.js
index aa7f1d3..e4cb062 100644
--- a/front_minimal/src/utils/axios.js
+++ b/front_minimal/src/utils/axios.js
@@ -13,6 +13,20 @@ axiosInstance.interceptors.response.use(
export default axiosInstance;
+// ----------------------------------------------------------------------
+// Resolve a relative or absolute media URL, always upgrading http → https
+// to avoid mixed-content errors on HTTPS pages.
+
+export function resolveMediaUrl(href) {
+ if (!href) return '';
+ let url = href;
+ if (!url.startsWith('http://') && !url.startsWith('https://')) {
+ const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
+ url = base + (url.startsWith('/') ? url : `/${url}`);
+ }
+ return url.replace(/^http:\/\//i, 'https://');
+}
+
// ----------------------------------------------------------------------
export const fetcher = async (args) => {
diff --git a/front_minimal/src/utils/board-api.js b/front_minimal/src/utils/board-api.js
index 0b96b9a..6f980db 100644
--- a/front_minimal/src/utils/board-api.js
+++ b/front_minimal/src/utils/board-api.js
@@ -1,4 +1,46 @@
import axios from 'src/utils/axios';
+import { CONFIG } from 'src/config-global';
+
+// ----------------------------------------------------------------------
+
+/**
+ * Build the Excalidraw iframe URL with all required params (boardId, token, apiUrl, yjsPort, isMentor).
+ */
+export function buildExcalidrawSrc(boardId, user) {
+ const token =
+ typeof window !== 'undefined'
+ ? localStorage.getItem('jwt_access_token') || localStorage.getItem('access_token') || ''
+ : '';
+
+ const serverUrl = CONFIG.site.serverUrl || '';
+ const apiUrl = serverUrl.replace(/\/api\/?$/, '') || '';
+ const isMentor = user?.role === 'mentor';
+
+ const excalidrawUrl = (process.env.NEXT_PUBLIC_EXCALIDRAW_URL || '').trim().replace(/\/?$/, '');
+ const excalidrawPath = process.env.NEXT_PUBLIC_EXCALIDRAW_PATH || '';
+ const excalidrawPort = process.env.NEXT_PUBLIC_EXCALIDRAW_PORT || '3001';
+ const yjsPort = process.env.NEXT_PUBLIC_YJS_PORT || '1236';
+
+ if (excalidrawUrl && excalidrawUrl.startsWith('http')) {
+ const url = new URL(`${excalidrawUrl}/`);
+ url.searchParams.set('boardId', boardId);
+ url.searchParams.set('apiUrl', apiUrl);
+ url.searchParams.set('yjsPort', yjsPort);
+ if (token) url.searchParams.set('token', token);
+ if (isMentor) url.searchParams.set('isMentor', '1');
+ return url.toString();
+ }
+
+ const origin = excalidrawPath
+ ? (typeof window !== 'undefined' ? window.location.origin : '')
+ : `${typeof window !== 'undefined' ? window.location.protocol : 'https:'}//${typeof window !== 'undefined' ? window.location.hostname : ''}:${excalidrawPort}`;
+ const pathname = excalidrawPath ? `/${excalidrawPath.replace(/^\//, '')}/` : '/';
+ const params = new URLSearchParams({ boardId, apiUrl });
+ params.set('yjsPort', yjsPort);
+ if (token) params.set('token', token);
+ if (isMentor) params.set('isMentor', '1');
+ return `${origin}${pathname}?${params.toString()}`;
+}
// ----------------------------------------------------------------------
@@ -26,13 +68,30 @@ export async function getOrCreateMentorStudentBoard(mentorId, studentId) {
return res.data;
}
+/**
+ * GET /board/boards/get-or-create-group/?group=G
+ */
+export async function getOrCreateGroupBoard(groupId) {
+ const res = await axios.get(`/board/boards/get-or-create-group/?group=${groupId}`);
+ return res.data;
+}
+
/**
* GET or create board for a lesson.
- * Resolves lesson → mentor/student IDs → getOrCreateMentorStudentBoard
+ * For group lessons → getOrCreateGroupBoard
+ * For individual lessons → getOrCreateMentorStudentBoard
*/
export async function getOrCreateLessonBoard(lessonId) {
const lessonRes = await axios.get(`/schedule/lessons/${lessonId}/`);
const lesson = lessonRes.data;
+
+ // Групповое занятие
+ const groupId = lesson.group?.id ?? (typeof lesson.group === 'number' ? lesson.group : null);
+ if (groupId) {
+ return getOrCreateGroupBoard(groupId);
+ }
+
+ // Индивидуальное занятие
const mentorId = typeof lesson.mentor === 'object' ? lesson.mentor?.id : lesson.mentor;
const client = lesson.client;
let studentId;
diff --git a/front_minimal/src/utils/chat-api.js b/front_minimal/src/utils/chat-api.js
index 8342e82..473dddb 100644
--- a/front_minimal/src/utils/chat-api.js
+++ b/front_minimal/src/utils/chat-api.js
@@ -3,17 +3,21 @@ import axios from 'src/utils/axios';
// ----------------------------------------------------------------------
export function normalizeChat(c) {
+ const isGroup = c?.chat_type === 'group';
const other = c?.other_participant ?? {};
- const name =
- other.full_name ||
- [other.first_name, other.last_name].filter(Boolean).join(' ') ||
- c?.participant_name ||
- 'Чат';
+ const name = isGroup
+ ? (c?.name || 'Группа')
+ : (other.full_name ||
+ [other.first_name, other.last_name].filter(Boolean).join(' ') ||
+ c?.participant_name ||
+ 'Чат');
const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || '';
return {
...c,
participant_name: name,
- participant_id: other.id ?? c?.other_user_id ?? c?.participant_id ?? null,
+ participant_id: isGroup ? null : (other.id ?? c?.other_user_id ?? c?.participant_id ?? null),
+ // Тип роли собеседника для прямых чатов
+ other_role: isGroup ? null : (other.role ?? c?.other_role ?? null),
avatar_url: other.avatar_url || other.avatar || c?.avatar_url || null,
last_message: lastText,
unread_count: c?.my_participant?.unread_count ?? c?.unread_count ?? 0,
@@ -80,6 +84,13 @@ export async function markMessagesAsRead(chatUuid, messageUuids) {
);
}
+export async function createGroupChat(groupId) {
+ const res = await axios.post('/chat/chats/create_group/', { group_id: groupId });
+ const { data } = res;
+ if (data && typeof data === 'object' && 'data' in data && typeof data.data === 'object') return data.data;
+ return data;
+}
+
export async function searchUsers(query) {
const res = await axios.get('/users/search/', { params: { q: query } });
const {data} = res;
diff --git a/front_minimal/src/utils/dashboard-api.js b/front_minimal/src/utils/dashboard-api.js
index 5a34a1c..d2fc4d6 100644
--- a/front_minimal/src/utils/dashboard-api.js
+++ b/front_minimal/src/utils/dashboard-api.js
@@ -57,8 +57,11 @@ export async function getParentDashboard(options) {
/**
* GET /schedule/lessons/calendar/?start_date=&end_date=
*/
-export async function getCalendarLessons(startDate, endDate, options) {
+export async function getCalendarLessons(startDate, endDate, extraParams, options) {
const params = new URLSearchParams({ start_date: startDate, end_date: endDate });
+ if (extraParams) {
+ Object.entries(extraParams).forEach(([k, v]) => { if (v != null) params.append(k, v); });
+ }
const config = options?.signal ? { signal: options.signal } : undefined;
const res = await axios.get(`/schedule/lessons/calendar/?${params}`, config);
return res.data;
@@ -152,6 +155,21 @@ export async function completeLesson(id, notes, mentorGrade, schoolGrade, homewo
return res.data;
}
+/**
+ * GET /schedule/lesson-files/?lesson={id}
+ */
+export async function getLessonFiles(lessonId) {
+ const res = await axios.get(`/schedule/lesson-files/?lesson=${lessonId}`);
+ return res.data;
+}
+
+/**
+ * DELETE /schedule/lesson-files/{id}/
+ */
+export async function deleteLessonFile(fileId) {
+ await axios.delete(`/schedule/lesson-files/${fileId}/`);
+}
+
/**
* POST /schedule/lesson-files/ (multipart)
*/
diff --git a/front_minimal/src/utils/homework-api.js b/front_minimal/src/utils/homework-api.js
index 68c2299..c156a29 100644
--- a/front_minimal/src/utils/homework-api.js
+++ b/front_minimal/src/utils/homework-api.js
@@ -7,6 +7,7 @@ export async function getHomework(params) {
if (params?.status) q.append('status', params.status);
if (params?.page_size) q.append('page_size', String(params.page_size || 1000));
if (params?.child_id) q.append('child_id', params.child_id);
+ if (params?.lesson_id) q.append('lesson', params.lesson_id);
const query = q.toString();
const url = `/homework/homeworks/${query ? `?${query}` : ''}`;
const res = await axios.get(url);
@@ -106,6 +107,8 @@ export async function uploadHomeworkFile(homeworkId, file) {
formData.append('homework', String(homeworkId));
formData.append('file_type', 'assignment');
formData.append('file', file);
+ formData.append('filename', file.name);
+ formData.append('file_size', String(file.size));
const res = await axios.post('/homework/files/', formData);
return res.data;
}
diff --git a/front_minimal/src/utils/livekit-api.js b/front_minimal/src/utils/livekit-api.js
index eb6fce8..ffecfb6 100644
--- a/front_minimal/src/utils/livekit-api.js
+++ b/front_minimal/src/utils/livekit-api.js
@@ -26,3 +26,12 @@ export async function participantConnected({ roomName, lessonId }) {
if (lessonId != null) body.lesson_id = lessonId;
await axios.post('/video/livekit/participant-connected/', body);
}
+
+/**
+ * POST /video/livekit/terminate-room/
+ * Terminates the LiveKit room and disconnects all participants.
+ */
+export async function terminateRoom(lessonId) {
+ const res = await axios.post('/video/livekit/terminate-room/', { lesson_id: lessonId });
+ return res.data;
+}
diff --git a/front_minimal/src/utils/profile-api.js b/front_minimal/src/utils/profile-api.js
index e415f7b..c51da2c 100644
--- a/front_minimal/src/utils/profile-api.js
+++ b/front_minimal/src/utils/profile-api.js
@@ -27,13 +27,12 @@ export async function updateProfile(data) {
const formData = new FormData();
if (data.first_name !== undefined) formData.append('first_name', data.first_name);
if (data.last_name !== undefined) formData.append('last_name', data.last_name);
- if (data.phone !== undefined) formData.append('phone', data.phone);
if (data.bio !== undefined) formData.append('bio', data.bio);
formData.append('avatar', data.avatar);
- const res = await axios.patch('/profile/me/', formData);
+ const res = await axios.patch('/profile/update_profile/', formData);
return res.data;
}
- const res = await axios.patch('/profile/me/', data);
+ const res = await axios.patch('/profile/update_profile/', data);
return res.data;
}
diff --git a/front_minimal/src/utils/referrals-api.js b/front_minimal/src/utils/referrals-api.js
index 3808959..65c220e 100644
--- a/front_minimal/src/utils/referrals-api.js
+++ b/front_minimal/src/utils/referrals-api.js
@@ -1,7 +1,5 @@
import axios from 'src/utils/axios';
-// ----------------------------------------------------------------------
-
export async function getReferralProfile() {
try {
const res = await axios.get('/referrals/my_profile/');
@@ -21,10 +19,51 @@ export async function getReferralStats() {
}
export async function getMyReferrals() {
- const res = await axios.get('/referrals/my_referrals/');
- return res.data;
+ try {
+ const res = await axios.get('/referrals/my_referrals/');
+ return res.data;
+ } catch {
+ return null;
+ }
+}
+
+export async function getReferralLevels() {
+ try {
+ const res = await axios.get('/referrals/levels/');
+ return res.data;
+ } catch {
+ return [];
+ }
+}
+
+export async function getBonusBalance() {
+ try {
+ const res = await axios.get('/bonus/balance/');
+ return res.data;
+ } catch {
+ return null;
+ }
+}
+
+export async function getBonusTransactions() {
+ try {
+ const res = await axios.get('/bonus/transactions/');
+ return res.data;
+ } catch {
+ return [];
+ }
+}
+
+export async function getReferralEarnings() {
+ try {
+ const res = await axios.get('/bonus/earnings/');
+ return res.data;
+ } catch {
+ return [];
+ }
}
export async function setReferrer(referralCode) {
- await axios.post('/referrals/set_referrer/', { referral_code: referralCode.trim() });
+ const res = await axios.post('/referrals/set_referrer/', { referral_code: referralCode.trim() });
+ return res.data;
}
diff --git a/front_minimal/src/utils/students-api.js b/front_minimal/src/utils/students-api.js
index 039a84b..4b385f2 100644
--- a/front_minimal/src/utils/students-api.js
+++ b/front_minimal/src/utils/students-api.js
@@ -50,6 +50,12 @@ export async function getMyMentors() {
return res.data;
}
+// Client: my outgoing requests to mentors
+export async function getMyRequests() {
+ const res = await axios.get('/mentorship-requests/my-requests/');
+ return res.data;
+}
+
// Client: incoming invitations from mentors
export async function getMyInvitations() {
const res = await axios.get('/invitation/my-invitations/');
@@ -66,6 +72,18 @@ export async function rejectInvitationAsStudent(invitationId) {
return res.data;
}
+// Mentor: remove student
+export async function removeStudent(clientId) {
+ const res = await axios.delete(`/manage/clients/${clientId}/remove_client/`);
+ return res.data;
+}
+
+// Student: remove mentor
+export async function removeMentor(mentorId) {
+ const res = await axios.delete(`/student/mentors/${mentorId}/remove/`);
+ return res.data;
+}
+
// Public: get mentor info by invite link token (no auth required)
export async function getInviteLinkInfo(token) {
const res = await axios.get('/invitation/info-by-token/', { params: { token } });