From e49fa9e746c5d68409031bdb82908db7f58fbeed Mon Sep 17 00:00:00 2001 From: Dev Server Date: Fri, 13 Mar 2026 00:39:37 +0300 Subject: [PATCH] feat: subscriptions, referrals, students/mentors removal, email templates, calendar fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - Subscriptions: add duration_days field to plan, fix duration logic, remove allowed_durations restriction - Users: add StudentMentorViewSet (remove_mentor), fix remove_client (board access + notification) - Users: add STATUS_REMOVED to MentorStudentConnection, fix re-invite after removal - Users: add authentication_classes=[] to all public auth endpoints (fix user_not_found 401) - Users: fix verify-email and reset-password URLs in email tasks - Users: validate IANA timezone on registration - Schedule: add group/group_name to LessonCalendarItemSerializer - Referrals: add tasks.py for Celery, add process_pending_referral_bonuses to beat schedule - Email templates: redesign all 5 templates (gradient header, icons, Училл branding) Frontend: - Calendar: fix SWR revalidation after create/update/delete (match childId key), clear errors on tab switch - Students: add remove buttons with warning dialog (mentor removes student, student removes mentor) - Students: add tabs for client (Мои менторы / Входящие / Исходящие), fix pending_student filter - Payment: fix duration_days from plan, show Бесплатно for 0₽, show X дн. period - Referrals: full redesign — stats, levels progress, referrals list, earnings history, bonus balance - Sign-up: add referral code field, auto-fill from ?ref= param - Subscription guard: redirect mentor to /payment-platform if no active subscription - Error pages: translate to Russian - Page titles: dynamic Russian titles via usePageTitle hook - Logo: fix full-page reload on click (use react-router Link directly) - Favicon: use /logo/favicon.png - Remove logo from header (keep in nav only) Co-Authored-By: Claude Sonnet 4.6 --- backend/apps/referrals/tasks.py | 38 + backend/apps/schedule/serializers.py | 36 +- backend/apps/subscriptions/admin.py | 25 +- .../0012_add_duration_days_to_plan.py | 24 + backend/apps/subscriptions/models.py | 23 +- backend/apps/subscriptions/serializers.py | 31 +- backend/apps/subscriptions/views.py | 13 +- backend/apps/users/dashboard_views.py | 45 +- backend/apps/users/mentorship_views.py | 5 + .../migrations/0012_add_group_to_board.py | 24 + .../migrations/0014_add_removed_status.py | 29 + backend/apps/users/models.py | 2 + backend/apps/users/profile_views.py | 133 +- backend/apps/users/serializers.py | 13 +- backend/apps/users/signals.py | 25 +- backend/apps/users/tasks.py | 7 +- backend/apps/users/templates/emails/base.html | 83 +- .../templates/emails/mentor_invitation.html | 266 +-- .../templates/emails/password_reset.html | 224 +- .../templates/emails/student_welcome.html | 232 +-- .../users/templates/emails/verification.html | 200 +- .../apps/users/templates/emails/welcome.html | 222 +- backend/apps/users/urls.py | 2 + backend/apps/users/views.py | 31 +- backend/config/celery.py | 10 + front_minimal/index.html | 4 +- front_minimal/src/actions/calendar.js | 17 +- front_minimal/src/auth/context/jwt/action.js | 15 +- .../src/auth/guard/subscription-guard.jsx | 58 + .../src/components/chart/use-chart.js | 3 +- .../custom-popover/custom-popover.jsx | 1 + front_minimal/src/components/logo/logo.jsx | 62 +- .../nav-section/vertical/nav-list.jsx | 2 +- front_minimal/src/global.css | 1 + front_minimal/src/hooks/use-page-title.js | 59 + .../src/layouts/core/header-base.jsx | 3 - .../src/locales/localization-provider.jsx | 6 +- front_minimal/src/routes/sections.jsx | 12 +- .../analytics/view/analytics-view.jsx | 14 +- .../sections/auth/jwt/jwt-sign-up-view.jsx | 49 +- .../src/sections/board/view/board-view.jsx | 126 +- .../src/sections/calendar/calendar-form.jsx | 410 +++- .../sections/calendar/view/calendar-view.jsx | 19 +- .../sections/chat/view/chat-platform-view.jsx | 1454 ++++++------- front_minimal/src/sections/error/403-view.jsx | 9 +- front_minimal/src/sections/error/500-view.jsx | 6 +- .../src/sections/error/not-found-view.jsx | 9 +- .../homework/homework-details-drawer.jsx | 17 +- .../homework/homework-submit-drawer.jsx | 12 +- .../src/sections/homework/view/index.js | 1 + .../materials/view/materials-view.jsx | 1844 +++++++++++++---- .../notifications/view/notifications-view.jsx | 37 +- .../course/view/overview-course-view.jsx | 20 +- .../payment/view/payment-platform-view.jsx | 766 +++++-- .../referrals/view/referrals-view.jsx | 588 ++++-- .../sections/students/view/students-view.jsx | 397 ++-- .../video-call/view/video-call-view.jsx | 496 +++-- front_minimal/src/styles/livekit-theme.css | 556 +++-- front_minimal/src/utils/axios.js | 14 + front_minimal/src/utils/board-api.js | 61 +- front_minimal/src/utils/chat-api.js | 23 +- front_minimal/src/utils/dashboard-api.js | 20 +- front_minimal/src/utils/homework-api.js | 3 + front_minimal/src/utils/livekit-api.js | 9 + front_minimal/src/utils/profile-api.js | 5 +- front_minimal/src/utils/referrals-api.js | 49 +- front_minimal/src/utils/students-api.js | 18 + 67 files changed, 5671 insertions(+), 3347 deletions(-) create mode 100644 backend/apps/referrals/tasks.py create mode 100644 backend/apps/subscriptions/migrations/0012_add_duration_days_to_plan.py create mode 100644 backend/apps/users/migrations/0012_add_group_to_board.py create mode 100644 backend/apps/users/migrations/0014_add_removed_status.py create mode 100644 front_minimal/src/auth/guard/subscription-guard.jsx create mode 100644 front_minimal/src/hooks/use-page-title.js 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 %} + - - - + +
+ +
+ + + + + - + + + + + + + + + + +
- - - - - - - - - - - - - - - - -
- - - - - -
- - uchill - -
-
- {% 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 - + Приглашение от ментора — Училл + - - - - - - -
- - - - - - - - - - - - - - - - -
- - - - - -
- - uchill - -
-
- - - - - - - - - - - - - - - - - {% if set_password_url %} - - - - - - - - - - - - - - - - {% elif confirm_url %} - - - - - - - - - - - - - - - {% endif %} -
-

Приглашение от ментора

-
-

- Здравствуйте! -

-
-

- {{ mentor_name }} приглашает вас в качестве ученика на платформу Uchill. -

-
-

- Для начала работы установите пароль и подтвердите приглашение. -

-
- - - - -
- - Установить пароль и подтвердить - -
-
- - - - -
-

- {{ set_password_url }} -

-
-
-

- Подтвердите приглашение, чтобы начать занятия с ментором. -

-
- - - - -
- - Подтвердить приглашение - -
-
- - - - -
-

- {{ confirm_url }} -

-
-
-
- - - - -
-

С уважением,
Команда 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 - + Восстановление пароля — Училл + - - - - - - -
- - - - - - - - - - - - - - - - -
- - - - - -
- - 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 - + Добро пожаловать на Училл + - - - - - - -
- - - - - - - - - - - - - - - - -
- - - - - -
- - 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 — Училл + - - - - - - -
- - - - - - - - - - - - - - - - -
- - - - - -
- - uchill - -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-

Подтверждение email

-
-

- Здравствуйте, {{ user_full_name }}! -

-
-

- Спасибо за регистрацию на Uchill. Для завершения регистрации необходимо подтвердить ваш email адрес. -

-
- - - - -
- - Подтвердить email - -
-
-

- Или скопируйте и вставьте эту ссылку в браузер: -

- - - - -
-

- {{ verification_url }} -

-
-
-

- Если вы не регистрировались на нашей платформе, просто проигнорируйте это письмо. -

-
-
- - - - -
-

С уважением,
Команда Uchill

-

- © 2026 Uchill. Все права защищены. -

-
-
-
+ + + +
+ + + + + + + + + + + + + + + + + +
+ Училл
+ Платформа для обучения +
+ + + + +
+ ✉️ +
+ +

Подтвердите ваш email

+

Осталось всего один шаг!

+ +

+ Здравствуйте, {{ user_full_name }}! +

+

+ Спасибо за регистрацию на Училл. Нажмите на кнопку ниже, чтобы подтвердить ваш адрес электронной почты и активировать аккаунт. +

+ + + + + + +
+ + Подтвердить email + +
+ + + + +
+

Или скопируйте ссылку:

+

{{ 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 - + Добро пожаловать на Училл + - - - - - - -
- - - - - - - - - - - - - - - - -
- - - - - -
- - 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) => ( + + + + ))} + {/* Индивидуальные доски */} + {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 ( - - - - {letter} - - {studentName} + {/* Тип занятия — только при создании */} + {!isEditing && ( + { if (val) setLessonType(val); }} + fullWidth + size="small" + > + + + Индивидуальное + + + + Групповое + + + )} + + {/* Ученик или Группа */} + {isGroup ? ( + + Выберите группу + {groups.map((g) => ( + + + + + {g.name} + {g.students_count != null && ( + + ({g.students_count} уч.) + + )} + - ); - })} - + ))} + + ) : ( + + Выберите ученика + {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 ? 'Сохранить' : 'Создать'} + + setConfirmDelete(false)} maxWidth="xs" fullWidth> + Удалить занятие? + + + Это действие нельзя отменить. Занятие будет безвозвратно удалено. + + + + + + + ); } 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 ( - - Новый чат - - setQuery(e.target.value)} - fullWidth - autoFocus - sx={{ mt: 1 }} - InputProps={{ - endAdornment: searching ? ( - - - - ) : null, - }} - /> - {error && ( - - {error} - - )} - - {results.map((u) => { - const name = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || ''; - return ( - handleCreate(u)} - disabled={creating} - sx={{ borderRadius: 1 }} - > - {getInitials(name)} - - - ); - })} - {!searching && query.trim() && results.length === 0 && ( - - Пользователи не найдены - - )} - - - - - - - ); -} - -// ---------------------------------------------------------------------- - -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 ( + + Новый чат + + setQuery(e.target.value)} + fullWidth autoFocus sx={{ mt: 1 }} + InputProps={{ + endAdornment: searching ? ( + + ) : null, + }} + /> + {error && {error}} + + {results.map((u) => { + const name = `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || ''; + return ( + handleCreate(u)} disabled={creating} sx={{ borderRadius: 1 }}> + {getChatInitials(name)} + + + ); + })} + {!searching && query.trim() && results.length === 0 && ( + + Пользователи не найдены + + )} + + + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( - - Добавить материал - - - setTitle(e.target.value)} - disabled={loading} - fullWidth - /> - setDescription(e.target.value)} - disabled={loading} - multiline - rows={2} - fullWidth - /> - - setFile(e.target.files?.[0] || null)} - disabled={loading} - style={{ display: 'none' }} - /> - - - {error && {error}} - - - - - - - - ); -} - -// ---------------------------------------------------------------------- - -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 */} - !deleting && setDeleteTarget(null)} maxWidth="xs" fullWidth> - Удалить материал? - - - «{deleteTarget?.title}» будет удалён безвозвратно. - - - - - - - - - ); -} +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 ( + + {/* Header */} + + + + {material.file_name || material.title} + + + {url && ( + + + + + + )} + + + + + + + + + {/* Content */} + + {renderContent()} + + + ); +} + +// ---------------------------------------------------------------------- +// 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 */} + + + {url && ( + + )} + + + {/* 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 }} + > + Поделиться с группой + + + )} + + + + + + )} + + + + ); +} + +// ---------------------------------------------------------------------- +// 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 ( + + Добавить материал + + + setTitle(e.target.value)} disabled={loading} fullWidth /> + setDescription(e.target.value)} disabled={loading} multiline rows={2} fullWidth /> + + setFile(e.target.files?.[0] || null)} disabled={loading} style={{ display: 'none' }} /> + + + {error && {error}} + + + + + + Сохранить + + + + ); +} + +// ---------------------------------------------------------------------- +// 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 && ( + + )} + + + {/* 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 */} + !deleting && setDeleteTarget(null)} maxWidth="xs" fullWidth> + Удалить материал? + + «{deleteTarget?.title}» будет удалён безвозвратно. + + + + + Удалить + + + + + ); +} 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 ( + + Подключить тариф + + + Вы выбрали тариф {plan.name}. + + {plan.trial_days > 0 && ( + + Первые {plan.trial_days} дней бесплатно + + )} + + {plan.subscription_type === 'per_student' + ? `Цена: ${plan.price_per_student ?? plan.price} ₽ за ученика` + : Number(plan.price) === 0 + ? 'Бесплатно' + : `Цена: ${plan.price} ₽ / ${getPeriodLabel(plan)}`} + + + + + + + + ); } // ---------------------------------------------------------------------- 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" - /> - )} - - - - - )} - - {/* 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 ( + + + {type === 'student' ? `Удалить ученика ${name}?` : `Удалить ментора ${name}?`} + + + + Это действие нельзя отменить автоматически + + + При удалении произойдёт следующее: + + + {[ + 'Все будущие занятия будут отменены', + 'Доступ к общим доскам будет приостановлен', + 'Доступ к материалам будет закрыт', + ].map((item) => ( + + + {item} + + ))} + + + Доски и файлы не удаляются. Если связь будет восстановлена — доступ вернётся автоматически. + + + + + + + + ); +} + // ---------------------------------------------------------------------- // 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 ? ( -