diff --git a/backend/apps/video/livekit_views.py b/backend/apps/video/livekit_views.py index 0a0c8ce..9533f51 100644 --- a/backend/apps/video/livekit_views.py +++ b/backend/apps/video/livekit_views.py @@ -90,12 +90,40 @@ def create_livekit_room(request): status=status.HTTP_403_FORBIDDEN ) - # Проверяем, есть ли LiveKit комната для этого занятия + # Если LiveKit комната не создана — создаём «на лету» (fallback при сбое при создании урока) if not lesson.livekit_room_name: - return Response( - {'error': 'LiveKit комната не создана для этого урока. Обратитесь к администратору.'}, - status=status.HTTP_500_INTERNAL_SERVER_ERROR - ) + try: + try: + existing = VideoRoom.objects.get(lesson=lesson) + room_name = str(existing.room_id) + except VideoRoom.DoesNotExist: + room_name = LiveKitService.generate_room_name() + client_user = lesson.client.user if hasattr(lesson.client, 'user') else lesson.client + VideoRoom.objects.create( + lesson=lesson, + mentor=lesson.mentor, + client=client_user, + room_id=room_name, + is_recording=True, + max_participants=10 if lesson.group else 2, + ) + mentor_token = LiveKitService.generate_access_token( + room_name=room_name, + participant_name=lesson.mentor.get_full_name(), + participant_identity=str(lesson.mentor.pk), + is_admin=True, + expires_in_minutes=1440, + ) + lesson.livekit_room_name = room_name + lesson.livekit_access_token = mentor_token + lesson.save(update_fields=['livekit_room_name', 'livekit_access_token']) + logger.info(f'LiveKit room created on-demand for lesson {lesson.id}') + except Exception: + logger.exception(f'Failed to create LiveKit room for lesson {lesson.id}') + return Response( + {'error': 'LiveKit комната не создана для этого урока. Обратитесь к администратору.'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) room_name = lesson.livekit_room_name diff --git a/backend/apps/video/signals.py b/backend/apps/video/signals.py index a8c7029..fe98522 100644 --- a/backend/apps/video/signals.py +++ b/backend/apps/video/signals.py @@ -1,127 +1,127 @@ -""" -Сигналы для видеоконференций. -""" -import logging -from django.db.models.signals import post_save -from django.dispatch import receiver -from django.utils import timezone -from apps.schedule.models import Lesson -from .models import VideoRoom - -logger = logging.getLogger(__name__) - - -@receiver(post_save, sender=Lesson) -def create_video_room_for_lesson(sender, instance, created, **kwargs): - """ - Автоматическое создание видеокомнаты и ссылки на встречу при создании занятия. - """ - # Создаем комнату для всех занятий (не только confirmed) - if created or not hasattr(instance, 'video_room'): - try: - # Проверяем, есть ли уже комната - try: - video_room = instance.video_room - except VideoRoom.DoesNotExist: - video_room = None - - if not video_room: - client_user = instance.client.user if hasattr(instance.client, 'user') else instance.client - video_room = VideoRoom.objects.create( - lesson=instance, - mentor=instance.mentor, - client=client_user, - is_recording=True, # По умолчанию включаем запись - max_participants=2 - ) - - logger.info(f'Создана видеокомната {video_room.room_id} для занятия {instance.id}') - - # Создаем или обновляем meeting_url на основе видеокомнаты (LiveKit) - from django.conf import settings - if not instance.meeting_url: - # Используем LiveKit вместо старого video room - # ВАЖНО: включаем lesson_id для синхронизации доски между пользователями - meeting_url = f'{settings.FRONTEND_URL}/livekit/{video_room.room_id}?lesson_id={instance.id}' - instance.meeting_url = meeting_url - # Сохраняем без вызова сигналов, чтобы избежать рекурсии - Lesson.objects.filter(id=instance.id).update(meeting_url=meeting_url) - logger.info(f'Создана ссылка на LiveKit встречу для занятия {instance.id}: {meeting_url}') - - # Планируем автоматическое удаление видеокомнаты через 10 минут после окончания занятия - if instance.end_time: - from .tasks import schedule_video_room_deletion - schedule_video_room_deletion.delay(instance.id) - logger.info(f'Запланировано автоматическое удаление видеокомнаты для занятия {instance.id}') - - # Отправляем уведомления о создании комнаты (только при создании) - if created: - from apps.notifications.services import NotificationService - - meeting_link = f'/video/rooms/{video_room.room_id}/join/' - - # Уведомление ментору - NotificationService.send_notification( - user=instance.mentor, - notification_type='video_room_created', - message=f'Создана видеокомната для занятия "{instance.title}"', - link=meeting_link - ) - - # Уведомление клиенту (если есть) - if instance.client: - try: - client_user = instance.client.user if hasattr(instance.client, 'user') else None - if client_user: - NotificationService.send_notification( - user=client_user, - notification_type='video_room_created', - message=f'Создана видеокомната для занятия "{instance.title}"', - link=meeting_link - ) - except Exception as e: - logger.warning(f'Не удалось отправить уведомление клиенту для занятия {instance.id}: {str(e)}') - - except Exception as e: - logger.error(f'Ошибка создания видеокомнаты для занятия {instance.id}: {str(e)}') - - -@receiver(post_save, sender=VideoRoom) -def handle_video_room_status_change(sender, instance, created, **kwargs): - """ - Обработка изменения статуса видеокомнаты. - """ - if not created: - # Если комната только что завершилась - if instance.status == 'ended' and instance.ended_at: - # Обновляем статус связанного занятия, если оно еще не завершено - try: - lesson = instance.lesson - if lesson.status in ['scheduled', 'in_progress']: - lesson.status = 'completed' - lesson.completed_at = timezone.now() - lesson.save(update_fields=['status', 'completed_at']) - logger.info(f'Статус занятия {lesson.id} обновлен на "completed" после завершения видеокомнаты') - except Exception as e: - logger.error(f'Ошибка обновления статуса занятия для видеокомнаты {instance.room_id}: {str(e)}') - - # Генерируем лог звонка - from .tasks import generate_call_log - generate_call_log.delay(instance.id) - - logger.info(f'Комната {instance.room_id} завершена, запущена генерация лога') - - # Если есть запись, начинаем обработку - if instance.is_recording: - from .models import ScreenRecording - - # Проверяем есть ли запись - try: - recording = ScreenRecording.objects.get(room=instance) - if recording.status == 'processing': - from .tasks import process_recording - process_recording.delay(recording.id) - logger.info(f'Запущена обработка записи {recording.id}') - except ScreenRecording.DoesNotExist: - logger.warning(f'Запись для комнаты {instance.room_id} не найдена') - +""" +Сигналы для видеоконференций. +""" +import logging +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone +from apps.schedule.models import Lesson +from .models import VideoRoom + +logger = logging.getLogger(__name__) + + +@receiver(post_save, sender=Lesson) +def create_video_room_for_lesson(sender, instance, created, **kwargs): + """ + Автоматическое создание видеокомнаты и ссылки на встречу при создании занятия. + """ + # Создаем комнату для всех занятий (не только confirmed) + if created or not hasattr(instance, 'video_room'): + try: + # Проверяем, есть ли уже комната + try: + video_room = instance.video_room + except VideoRoom.DoesNotExist: + video_room = None + + if not video_room: + client_user = instance.client.user if hasattr(instance.client, 'user') else instance.client + video_room = VideoRoom.objects.create( + lesson=instance, + mentor=instance.mentor, + client=client_user, + is_recording=True, # По умолчанию включаем запись + max_participants=2 + ) + + logger.info(f'Создана видеокомната {video_room.room_id} для занятия {instance.id}') + + # Создаем или обновляем meeting_url на основе видеокомнаты (LiveKit) + from django.conf import settings + if not instance.meeting_url: + # Используем LiveKit вместо старого video room + # ВАЖНО: включаем lesson_id для синхронизации доски между пользователями + meeting_url = f'{settings.FRONTEND_URL}/livekit/{video_room.room_id}?lesson_id={instance.id}' + instance.meeting_url = meeting_url + # Сохраняем без вызова сигналов, чтобы избежать рекурсии + Lesson.objects.filter(id=instance.id).update(meeting_url=meeting_url) + logger.info(f'Создана ссылка на LiveKit встречу для занятия {instance.id}: {meeting_url}') + + # Планируем автоматическое удаление видеокомнаты через 10 минут после окончания занятия + if instance.end_time: + from .tasks import schedule_video_room_deletion + schedule_video_room_deletion.delay(instance.id) + logger.info(f'Запланировано автоматическое удаление видеокомнаты для занятия {instance.id}') + + # Отправляем уведомления о создании комнаты (только при создании) + if created: + from apps.notifications.services import NotificationService + + meeting_link = f'/video/rooms/{video_room.room_id}/join/' + + # Уведомление ментору + NotificationService.send_notification( + user=instance.mentor, + notification_type='video_room_created', + message=f'Создана видеокомната для занятия "{instance.title}"', + link=meeting_link + ) + + # Уведомление клиенту (если есть) + if instance.client: + try: + client_user = instance.client.user if hasattr(instance.client, 'user') else None + if client_user: + NotificationService.send_notification( + user=client_user, + notification_type='video_room_created', + message=f'Создана видеокомната для занятия "{instance.title}"', + link=meeting_link + ) + except Exception as e: + logger.warning(f'Не удалось отправить уведомление клиенту для занятия {instance.id}: {str(e)}') + + except Exception as e: + logger.error(f'Ошибка создания видеокомнаты для занятия {instance.id}: {str(e)}') + + +@receiver(post_save, sender=VideoRoom) +def handle_video_room_status_change(sender, instance, created, **kwargs): + """ + Обработка изменения статуса видеокомнаты. + """ + if not created: + # Если комната только что завершилась + if instance.status == 'ended' and instance.ended_at: + # Обновляем статус связанного занятия, если оно еще не завершено + try: + lesson = instance.lesson + if lesson.status in ['scheduled', 'in_progress']: + lesson.status = 'completed' + lesson.completed_at = timezone.now() + lesson.save(update_fields=['status', 'completed_at']) + logger.info(f'Статус занятия {lesson.id} обновлен на "completed" после завершения видеокомнаты') + except Exception as e: + logger.error(f'Ошибка обновления статуса занятия для видеокомнаты {instance.room_id}: {str(e)}') + + # Генерируем лог звонка + from .tasks import generate_call_log + generate_call_log.delay(instance.id) + + logger.info(f'Комната {instance.room_id} завершена, запущена генерация лога') + + # Если есть запись, начинаем обработку + if instance.is_recording: + from .models import ScreenRecording + + # Проверяем есть ли запись + try: + recording = ScreenRecording.objects.get(room=instance) + if recording.status == 'processing': + from .tasks import process_recording + process_recording.delay(recording.id) + logger.info(f'Запущена обработка записи {recording.id}') + except ScreenRecording.DoesNotExist: + logger.warning(f'Запись для комнаты {instance.room_id} не найдена') + diff --git a/backend/config/celery.py b/backend/config/celery.py index 8f7933c..dd689a2 100644 --- a/backend/config/celery.py +++ b/backend/config/celery.py @@ -1,168 +1,168 @@ -""" -Celery конфигурация для фоновых задач. -""" -import os -from celery import Celery -from celery.schedules import crontab -from celery import signals - -# Установка настроек Django для Celery -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') - -app = Celery('platform') - -# Загрузка конфигурации из Django settings с префиксом CELERY_ -app.config_from_object('django.conf:settings', namespace='CELERY') - -# Автоматическое обнаружение tasks.py в приложениях -app.autodiscover_tasks() - - -@signals.worker_ready.connect -def on_worker_ready(sender=None, **kwargs): - """ - Запуск задач при старте Celery worker. - Обрабатывает накопившиеся занятия, которые нужно обновить. - """ - from apps.schedule.tasks import start_lessons_automatically - try: - # Запускаем задачу синхронно при старте worker, чтобы сразу обработать накопившиеся занятия - start_lessons_automatically() - import logging - logger = logging.getLogger(__name__) - logger.info('[Celery] Задача start_lessons_automatically выполнена при старте worker') - except Exception as e: - import logging - logger = logging.getLogger(__name__) - logger.warning(f'[Celery] Ошибка при запуске start_lessons_automatically при старте: {str(e)}') - -# Периодические задачи (Celery Beat) -app.conf.beat_schedule = { - # ============================================ - # ЗАДАЧИ ПОДПИСОК - # ============================================ - - # Проверка истекших подписок каждый день в 00:00 - 'check-expired-subscriptions': { - 'task': 'apps.subscriptions.tasks.check_expired_subscriptions', - 'schedule': crontab(hour=0, minute=0), # Каждый день в 00:00 - }, - - # Отправка предупреждений об истечении подписок каждый день в 10:00 - 'send-expiration-warnings': { - 'task': 'apps.subscriptions.tasks.send_expiration_warnings', - 'schedule': crontab(hour=10, minute=0), # Каждый день в 10:00 - }, - - # Автопродление подписок каждый день в 02:00 - 'auto-renew-subscriptions': { - 'task': 'apps.subscriptions.tasks.auto_renew_subscriptions', - 'schedule': crontab(hour=2, minute=0), # Каждый день в 02:00 - }, - - # Сброс месячного использования 1-го числа каждого месяца в 00:00 - 'reset-monthly-usage': { - 'task': 'apps.subscriptions.tasks.reset_monthly_usage', - 'schedule': crontab(day_of_month=1, hour=0, minute=0), # 1-го числа в 00:00 - }, - - # Очистка старой истории платежей 1-го числа каждого месяца в 03:00 - 'cleanup-old-payment-history': { - 'task': 'apps.subscriptions.tasks.cleanup_old_payment_history', - 'schedule': crontab(day_of_month=1, hour=3, minute=0), # 1-го числа в 03:00 - }, - - # Генерация отчетов по подпискам 1-го числа каждого месяца в 09:00 - 'generate-subscription-reports': { - 'task': 'apps.subscriptions.tasks.generate_subscription_reports', - 'schedule': crontab(day_of_month=1, hour=9, minute=0), # 1-го числа в 09:00 - }, - - # ============================================ - # ЗАДАЧИ РАСПИСАНИЯ - # ============================================ - - # Автоматическое начало и завершение занятий по времени (каждую минуту) - 'start-lessons-automatically': { - 'task': 'apps.schedule.tasks.start_lessons_automatically', - 'schedule': 60.0, # каждые 60 секунд - }, - - # Отправка напоминаний о занятиях за 1 час - 'send-lesson-reminders': { - 'task': 'apps.schedule.tasks.send_lesson_reminders', - 'schedule': crontab(minute='*/15'), # Каждые 15 минут - }, - - # Отправка запросов о подтверждении присутствия за 3 часа до занятия - 'send-attendance-confirmation-requests': { - 'task': 'apps.schedule.tasks.send_attendance_confirmation_requests', - 'schedule': crontab(minute='*/10'), # Каждые 10 минут - }, - - # Отправка отложенных уведомлений каждую минуту - 'send-scheduled-notifications': { - 'task': 'apps.notifications.tasks.send_scheduled_notifications', - 'schedule': 60.0, # Каждые 60 секунд - }, - - # Очистка старых уведомлений каждый день в 3:00 - 'cleanup-old-notifications': { - 'task': 'apps.notifications.tasks.cleanup_old_notifications', - 'schedule': crontab(hour=3, minute=0), - }, - - # Очистка старых файлов домашних заданий каждую неделю - 'cleanup-old-homework-files': { - 'task': 'apps.homework.tasks.cleanup_old_files', - 'schedule': crontab(day_of_week=0, hour=2, minute=0), # Воскресенье в 2:00 - }, - - # Автоматическое завершение неактивных видеокомнат (каждые 5 минут) - 'end-inactive-video-rooms': { - 'task': 'apps.video.tasks.end_inactive_rooms', - 'schedule': 300.0, # каждые 5 минут - }, - - # Поддержание 12 будущих занятий для повторяющихся занятий (каждый день в 2:00) - 'maintain-recurring-lessons': { - 'task': 'apps.schedule.tasks.maintain_recurring_lessons', - 'schedule': crontab(hour=2, minute=0), # Каждый день в 2:00 - }, - - # Перенос кастомных предметов в общую модель (каждый день в 3:00) - 'promote-mentor-subjects-to-subjects': { - 'task': 'apps.schedule.tasks.promote_mentor_subjects_to_subjects', - 'schedule': crontab(hour=3, minute=0), # Каждый день в 3:00 - }, - - # Автоматическое создание бэкапа базы данных каждый день в 2:00 - 'backup-database': { - 'task': 'apps.core.tasks.backup_database', - 'schedule': crontab(hour=2, minute=0), # Каждый день в 2:00 - }, - - # Очистка старых бэкапов каждый день в 4:00 - 'cleanup-old-backups': { - 'task': 'apps.core.tasks.cleanup_old_backups', - 'schedule': crontab(hour=4, minute=0), # Каждый день в 4:00 - }, - - # Синхронизация квот хранилища с подписками каждый день в 1:00 - 'sync-all-storage-quotas': { - 'task': 'apps.materials.tasks.sync_all_storage_quotas', - 'schedule': crontab(hour=1, minute=0), # Каждый день в 1:00 - }, - - # Очистка старых неиспользуемых материалов каждую неделю в воскресенье в 3:00 - 'cleanup-old-unused-materials': { - 'task': 'apps.materials.tasks.cleanup_old_unused_materials', - 'schedule': crontab(day_of_week=0, hour=3, minute=0), # Воскресенье в 3:00 - }, -} - -@app.task(bind=True, ignore_result=True) -def debug_task(self): - """Тестовая задача для проверки работы Celery.""" - print(f'Request: {self.request!r}') - +""" +Celery конфигурация для фоновых задач. +""" +import os +from celery import Celery +from celery.schedules import crontab +from celery import signals + +# Установка настроек Django для Celery +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +app = Celery('platform') + +# Загрузка конфигурации из Django settings с префиксом CELERY_ +app.config_from_object('django.conf:settings', namespace='CELERY') + +# Автоматическое обнаружение tasks.py в приложениях +app.autodiscover_tasks() + + +@signals.worker_ready.connect +def on_worker_ready(sender=None, **kwargs): + """ + Запуск задач при старте Celery worker. + Обрабатывает накопившиеся занятия, которые нужно обновить. + """ + from apps.schedule.tasks import start_lessons_automatically + try: + # Запускаем задачу синхронно при старте worker, чтобы сразу обработать накопившиеся занятия + start_lessons_automatically() + import logging + logger = logging.getLogger(__name__) + logger.info('[Celery] Задача start_lessons_automatically выполнена при старте worker') + except Exception as e: + import logging + logger = logging.getLogger(__name__) + logger.warning(f'[Celery] Ошибка при запуске start_lessons_automatically при старте: {str(e)}') + +# Периодические задачи (Celery Beat) +app.conf.beat_schedule = { + # ============================================ + # ЗАДАЧИ ПОДПИСОК + # ============================================ + + # Проверка истекших подписок каждый день в 00:00 + 'check-expired-subscriptions': { + 'task': 'apps.subscriptions.tasks.check_expired_subscriptions', + 'schedule': crontab(hour=0, minute=0), # Каждый день в 00:00 + }, + + # Отправка предупреждений об истечении подписок каждый день в 10:00 + 'send-expiration-warnings': { + 'task': 'apps.subscriptions.tasks.send_expiration_warnings', + 'schedule': crontab(hour=10, minute=0), # Каждый день в 10:00 + }, + + # Автопродление подписок каждый день в 02:00 + 'auto-renew-subscriptions': { + 'task': 'apps.subscriptions.tasks.auto_renew_subscriptions', + 'schedule': crontab(hour=2, minute=0), # Каждый день в 02:00 + }, + + # Сброс месячного использования 1-го числа каждого месяца в 00:00 + 'reset-monthly-usage': { + 'task': 'apps.subscriptions.tasks.reset_monthly_usage', + 'schedule': crontab(day_of_month=1, hour=0, minute=0), # 1-го числа в 00:00 + }, + + # Очистка старой истории платежей 1-го числа каждого месяца в 03:00 + 'cleanup-old-payment-history': { + 'task': 'apps.subscriptions.tasks.cleanup_old_payment_history', + 'schedule': crontab(day_of_month=1, hour=3, minute=0), # 1-го числа в 03:00 + }, + + # Генерация отчетов по подпискам 1-го числа каждого месяца в 09:00 + 'generate-subscription-reports': { + 'task': 'apps.subscriptions.tasks.generate_subscription_reports', + 'schedule': crontab(day_of_month=1, hour=9, minute=0), # 1-го числа в 09:00 + }, + + # ============================================ + # ЗАДАЧИ РАСПИСАНИЯ + # ============================================ + + # Автоматическое начало и завершение занятий по времени (каждую минуту) + 'start-lessons-automatically': { + 'task': 'apps.schedule.tasks.start_lessons_automatically', + 'schedule': 60.0, # каждые 60 секунд + }, + + # Отправка напоминаний о занятиях за 1 час + 'send-lesson-reminders': { + 'task': 'apps.schedule.tasks.send_lesson_reminders', + 'schedule': crontab(minute='*/15'), # Каждые 15 минут + }, + + # Отправка запросов о подтверждении присутствия за 3 часа до занятия + 'send-attendance-confirmation-requests': { + 'task': 'apps.schedule.tasks.send_attendance_confirmation_requests', + 'schedule': crontab(minute='*/10'), # Каждые 10 минут + }, + + # Отправка отложенных уведомлений каждую минуту + 'send-scheduled-notifications': { + 'task': 'apps.notifications.tasks.send_scheduled_notifications', + 'schedule': 60.0, # Каждые 60 секунд + }, + + # Очистка старых уведомлений каждый день в 3:00 + 'cleanup-old-notifications': { + 'task': 'apps.notifications.tasks.cleanup_old_notifications', + 'schedule': crontab(hour=3, minute=0), + }, + + # Очистка старых файлов домашних заданий каждую неделю + 'cleanup-old-homework-files': { + 'task': 'apps.homework.tasks.cleanup_old_files', + 'schedule': crontab(day_of_week=0, hour=2, minute=0), # Воскресенье в 2:00 + }, + + # Автоматическое завершение неактивных видеокомнат (каждые 5 минут) + 'end-inactive-video-rooms': { + 'task': 'apps.video.tasks.end_inactive_rooms', + 'schedule': 300.0, # каждые 5 минут + }, + + # Поддержание 12 будущих занятий для повторяющихся занятий (каждый день в 2:00) + 'maintain-recurring-lessons': { + 'task': 'apps.schedule.tasks.maintain_recurring_lessons', + 'schedule': crontab(hour=2, minute=0), # Каждый день в 2:00 + }, + + # Перенос кастомных предметов в общую модель (каждый день в 3:00) + 'promote-mentor-subjects-to-subjects': { + 'task': 'apps.schedule.tasks.promote_mentor_subjects_to_subjects', + 'schedule': crontab(hour=3, minute=0), # Каждый день в 3:00 + }, + + # Автоматическое создание бэкапа базы данных каждый день в 2:00 + 'backup-database': { + 'task': 'apps.core.tasks.backup_database', + 'schedule': crontab(hour=2, minute=0), # Каждый день в 2:00 + }, + + # Очистка старых бэкапов каждый день в 4:00 + 'cleanup-old-backups': { + 'task': 'apps.core.tasks.cleanup_old_backups', + 'schedule': crontab(hour=4, minute=0), # Каждый день в 4:00 + }, + + # Синхронизация квот хранилища с подписками каждый день в 1:00 + 'sync-all-storage-quotas': { + 'task': 'apps.materials.tasks.sync_all_storage_quotas', + 'schedule': crontab(hour=1, minute=0), # Каждый день в 1:00 + }, + + # Очистка старых неиспользуемых материалов каждую неделю в воскресенье в 3:00 + 'cleanup-old-unused-materials': { + 'task': 'apps.materials.tasks.cleanup_old_unused_materials', + 'schedule': crontab(day_of_week=0, hour=3, minute=0), # Воскресенье в 3:00 + }, +} + +@app.task(bind=True, ignore_result=True) +def debug_task(self): + """Тестовая задача для проверки работы Celery.""" + print(f'Request: {self.request!r}') + diff --git a/backend/config/jwt_settings.py b/backend/config/jwt_settings.py index 3cb8713..6371a1a 100644 --- a/backend/config/jwt_settings.py +++ b/backend/config/jwt_settings.py @@ -9,8 +9,8 @@ from datetime import timedelta # ============================================== SIMPLE_JWT = { - # Время жизни access токена (15 минут) - 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), + # Время жизни access токена (3 часа - достаточно для длинных уроков) + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=3), # Время жизни refresh токена (7 дней) 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), diff --git a/backend/config/settings.py b/backend/config/settings.py index 6c323be..e436239 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -403,7 +403,7 @@ REST_FRAMEWORK = { # ============================================== SIMPLE_JWT = { - 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1), + 'ACCESS_TOKEN_LIFETIME': timedelta(hours=3), # 3 часа - достаточно для длинных уроков 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), 'ROTATE_REFRESH_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True, diff --git a/front_material/app/(protected)/analytics/page.tsx b/front_material/app/(protected)/analytics/page.tsx index c783069..a41cd63 100644 --- a/front_material/app/(protected)/analytics/page.tsx +++ b/front_material/app/(protected)/analytics/page.tsx @@ -1,512 +1,512 @@ -'use client'; - -import { useState, useEffect, useCallback, useMemo } from 'react'; -import { Swiper, SwiperSlide } from 'swiper/react'; -import 'swiper/css'; - -/** Высота графиков на странице аналитики — доля от высоты экрана (обновляется при resize). */ -function useAnalyticsChartHeight(fraction = 0.55) { - const [height, setHeight] = useState(300); - useEffect(() => { - const update = () => setHeight(Math.round((typeof window !== 'undefined' ? window.innerHeight : 600) * fraction)); - update(); - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); - }, [fraction]); - return height; -} -import dynamic from 'next/dynamic'; -import { useAuth } from '@/contexts/AuthContext'; -import { LoadingSpinner } from '@/components/common/LoadingSpinner'; -import { getIncomeStats } from '@/api/income'; -import { - getLast30DaysRange, - toAnalyticsRange, - getAnalyticsOverview, - getAnalyticsStudents, - getAnalyticsRevenue, - getAnalyticsGradesByDay, - type StudentStat, - type AnalyticsRevenueResponse, -} from '@/api/analytics'; -import { - DashboardLayout, - Panel, - SectionHeader, - ListRow, -} from '@/components/dashboard/ui'; -import { RevenueChart } from '@/components/dashboard/RevenueChart'; -import type { IncomeChartData } from '@/api/dashboard'; -import { DateRangePicker } from '@/components/common/DateRangePicker'; -import { KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material'; -import type { Swiper as SwiperType } from 'swiper'; - -const ApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); -import type { ApexOptions } from 'apexcharts'; - -const navButtonStyle: React.CSSProperties = { - width: 40, - height: 40, - padding: 0, - borderRadius: 14, - border: 'none', - background: 'var(--md-sys-color-primary)', - color: 'var(--md-sys-color-on-primary)', - fontSize: 20, - cursor: 'pointer', - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - flexShrink: 0, - boxShadow: 'var(--ios26-shadow-soft)', - transition: 'opacity 0.2s ease, box-shadow 0.2s ease', -}; - -type DateRange = { start_date: string; end_date: string }; - -const formatCurrency = (v: number) => - new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v); - -export default function AnalyticsPage() { - const { user } = useAuth(); - const chartHeight = useAnalyticsChartHeight(0.8); - const defaultRange = getLast30DaysRange(); - - const [rangeIncome, setRangeIncome] = useState(() => defaultRange); - const [rangeLessons, setRangeLessons] = useState(() => defaultRange); - const [rangeSuccess, setRangeSuccess] = useState(() => defaultRange); - - const [incomeData, setIncomeData] = useState(null); - const [incomeLoading, setIncomeLoading] = useState(true); - const [overviewLessons, setOverviewLessons] = useState(null); - const [revenueLessons, setRevenueLessons] = useState(null); - const [incomeForLessons, setIncomeForLessons] = useState<{ chart_data: { date: string; income: number; lessons: number }[] } | null>(null); - const [lessonsLoading, setLessonsLoading] = useState(true); - const [studentsSuccess, setStudentsSuccess] = useState<{ students: StudentStat[] } | null>(null); - const [overviewSuccess, setOverviewSuccess] = useState(null); - const [gradesByDaySuccess, setGradesByDaySuccess] = useState<{ - by_day: { date: string; average_grade: number | null; lessons_count: number; graded_count: number }[]; - summary: { total_lessons: number; graded_lessons: number; average_grade: number }; - } | null>(null); - const [successLoading, setSuccessLoading] = useState(true); - const [swiperInstance, setSwiperInstance] = useState(null); - - const loadIncome = useCallback(async () => { - if (user?.role !== 'mentor') return; - setIncomeLoading(true); - try { - const d = await getIncomeStats({ - period: 'range', - start_date: rangeIncome.start_date, - end_date: rangeIncome.end_date, - }); - setIncomeData(d); - } catch { - setIncomeData(null); - } finally { - setIncomeLoading(false); - } - }, [user?.role, rangeIncome]); - - const loadLessons = useCallback(async () => { - if (user?.role !== 'mentor') return; - setLessonsLoading(true); - try { - const r = toAnalyticsRange(rangeLessons); - const [ov, rev, income] = await Promise.all([ - getAnalyticsOverview(r).catch(() => null), - getAnalyticsRevenue(r).catch(() => null), - getIncomeStats({ period: 'range', start_date: rangeLessons.start_date, end_date: rangeLessons.end_date }).catch(() => null), - ]); - setOverviewLessons(ov); - setRevenueLessons(rev); - setIncomeForLessons(income ?? null); - } catch { - setOverviewLessons(null); - setRevenueLessons(null); - setIncomeForLessons(null); - } finally { - setLessonsLoading(false); - } - }, [user?.role, rangeLessons]); - - const loadSuccess = useCallback(async () => { - if (user?.role !== 'mentor') return; - setSuccessLoading(true); - try { - const r = toAnalyticsRange(rangeSuccess); - const [stu, ov, grades] = await Promise.all([ - getAnalyticsStudents(r).catch(() => null), - getAnalyticsOverview(r).catch(() => null), - getAnalyticsGradesByDay(r).catch(() => null), - ]); - setStudentsSuccess(stu ? { students: stu.students } : null); - setOverviewSuccess(ov); - setGradesByDaySuccess(grades ? { by_day: grades.by_day, summary: grades.summary } : null); - } catch { - setStudentsSuccess(null); - setOverviewSuccess(null); - setGradesByDaySuccess(null); - } finally { - setSuccessLoading(false); - } - }, [user?.role, rangeSuccess]); - - useEffect(() => { loadIncome(); }, [loadIncome]); - useEffect(() => { loadLessons(); }, [loadLessons]); - useEffect(() => { loadSuccess(); }, [loadSuccess]); - - if (user?.role !== 'mentor') { - return ( -
- Аналитика доступна только менторам. -
- ); - } - - const incomeChartData: IncomeChartData[] = useMemo( - () => (incomeData?.chart_data ?? []).map((d: { date: string; income: number; lessons: number }) => ({ - date: d.date, - income: d.income, - lessons: d.lessons, - })), - [incomeData?.chart_data], - ); - - return ( - -
- - - {/* Доход */} - - } - /> - {incomeLoading && !incomeData ? ( -
- -
- ) : ( -
-
- -
-
-
-
Общий доход
-
- {incomeData?.summary ? formatCurrency(incomeData.summary.total_income || 0) : '—'} -
-
-
-
Средняя цена
-
- {incomeData?.summary ? formatCurrency(incomeData.summary.average_lesson_price || 0) : '—'} -
-
-
-
- )} -
-
- - - {/* Занятия */} - - } - /> - {lessonsLoading && !revenueLessons ? ( -
- -
- ) : ( -
-
- {(() => { - const byDay = revenueLessons?.by_day?.length - ? revenueLessons.by_day - : incomeForLessons?.chart_data?.map((d) => ({ date: d.date, revenue: d.income, lessons_count: d.lessons })) ?? []; - return byDay.length ? ( - - ) : ( -
- Нет данных за период -
- ); - })()} -
-
-
-
Всего
-
- {overviewLessons?.lessons?.total ?? '—'} -
-
-
-
Проведено
-
- {overviewLessons?.lessons?.completed ?? '—'} -
-
-
-
Отменено
-
- {overviewLessons?.lessons?.cancelled ?? '—'} -
-
-
-
- )} -
-
- - - {/* Успех учеников — средняя оценка по дням, продуктивность репетитора */} - - } - /> - {successLoading && !gradesByDaySuccess ? ( -
- -
- ) : ( -
-
- {gradesByDaySuccess?.by_day?.length ? ( - - ) : ( -
- Нет данных за период -
- )} -
-
-
-
Средняя оценка
-
{gradesByDaySuccess?.summary?.average_grade ?? overviewSuccess?.grades?.average ?? '—'}
-
-
-
Занятий с оценкой
-
{gradesByDaySuccess?.summary?.graded_lessons ?? '—'}
-
-
-
Активных учеников
-
{overviewSuccess?.students?.active ?? '—'}
-
-
-
- )} -
-
- - - {/* Топ занятий по доходам + Топ ученики — одна панель в 2 колонки */} - -
-
- } /> -
- {(() => { - const topLessons = (incomeData?.top_lessons ?? []).slice(0, 10); - if (!topLessons.length) { - return

Нет данных за период

; - } - return topLessons.map((item: any, i: number) => ( - - )); - })()} -
-
-
- } /> - {studentsSuccess?.students?.length ? ( -
- {studentsSuccess.students.slice(0, 10).map((s, i) => ( - - ))} -
- ) : ( -

Нет данных

- )} -
-
-
-
-
-
- - -
-
-
- ); -} - -function LessonsByDayChart({ byDay, height = 250 }: { byDay: { date: string; revenue: number; lessons_count: number }[]; height?: number }) { - const categories = useMemo(() => byDay.map((d) => d.date), [byDay]); - const series = useMemo(() => [{ name: 'Занятий', data: byDay.map((d) => d.lessons_count) }], [byDay]); - const options = useMemo( - () => ({ - chart: { - id: 'lessons-by-day', - toolbar: { - show: true, - tools: { zoomin: true, zoomout: true, pan: true, reset: true }, - autoSelected: 'pan' as const, - }, - zoom: { enabled: true, type: 'x' as const, allowMouseWheelZoom: true }, - pan: { enabled: true, type: 'x' as const }, - }, - stroke: { curve: 'smooth' as const, width: 2 }, - colors: ['var(--md-sys-color-primary)'], - dataLabels: { enabled: false }, - xaxis: { - categories, - tickPlacement: 'on', - axisBorder: { show: false }, - axisTicks: { show: false }, - labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, - }, - yaxis: { - labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, - }, - fill: { type: 'gradient' as const, gradient: { shadeIntensity: 0.5, opacityFrom: 0.5, opacityTo: 0.1 } }, - grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, - tooltip: { y: { formatter: (val: number) => `${val} занятий` } }, - }), - [categories], - ); - return ( -
- -
- ); -} - -function GradesByDayChart({ - byDay, - height = 250, -}: { - byDay: { date: string; average_grade: number | null; lessons_count: number; graded_count: number }[]; - height?: number; -}) { - const categories = useMemo(() => byDay.map((d) => d.date), [byDay]); - const series = useMemo( - () => [{ name: 'Средняя оценка', data: byDay.map((d) => d.average_grade ?? 0) }], - [byDay], - ); - const options = useMemo( - () => ({ - chart: { - id: 'grades-by-day', - toolbar: { - show: true, - tools: { zoomin: true, zoomout: true, pan: true, reset: true }, - autoSelected: 'pan' as const, - }, - zoom: { - enabled: true, - type: 'x' as const, - autoScaleYaxis: false, - allowMouseWheelZoom: true, - }, - pan: { enabled: true, type: 'x' as const }, - }, - stroke: { curve: 'smooth' as const, width: 2 }, - colors: ['var(--md-sys-color-primary)'], - dataLabels: { enabled: false }, - xaxis: { - categories, - tickPlacement: 'on', - axisBorder: { show: false }, - axisTicks: { show: false }, - labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, - }, - yaxis: { - min: 0, - max: 5, - labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, - }, - fill: { type: 'gradient' as const, gradient: { shadeIntensity: 0.5, opacityFrom: 0.5, opacityTo: 0.1 } }, - grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, - tooltip: { - y: { formatter: (val: number) => (val ? `Ср. оценка: ${val}` : '—') }, - }, - }), - [categories], - ); - return ( -
- -
- ); -} - -function StudentSuccessChart({ students }: { students: StudentStat[] }) { - const categories = useMemo(() => students.map((s) => s.name.length > 12 ? s.name.slice(0, 10) + '…' : s.name), [students]); - const series = useMemo(() => [{ name: 'Средняя оценка', data: students.map((s) => Number(s.average_grade) || 0) }], [students]); - const options = useMemo( - () => ({ - chart: { id: 'student-success', type: 'bar', toolbar: { show: false } }, - plotOptions: { bar: { borderRadius: 6, horizontal: false, columnWidth: '60%' } }, - colors: ['var(--md-sys-color-primary)'], - dataLabels: { enabled: false }, - xaxis: { - categories, - labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '11px' }, maxWidth: 100 }, - }, - yaxis: { - min: 0, - max: 5, - labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, - }, - grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, - tooltip: { y: { formatter: (val: number) => `Оценка: ${val}` } }, - }), - [categories], - ); - return ( -
- -
- ); -} +'use client'; + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Swiper, SwiperSlide } from 'swiper/react'; +import 'swiper/css'; + +/** Высота графиков на странице аналитики — доля от высоты экрана (обновляется при resize). */ +function useAnalyticsChartHeight(fraction = 0.55) { + const [height, setHeight] = useState(300); + useEffect(() => { + const update = () => setHeight(Math.round((typeof window !== 'undefined' ? window.innerHeight : 600) * fraction)); + update(); + window.addEventListener('resize', update); + return () => window.removeEventListener('resize', update); + }, [fraction]); + return height; +} +import dynamic from 'next/dynamic'; +import { useAuth } from '@/contexts/AuthContext'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; +import { getIncomeStats } from '@/api/income'; +import { + getLast30DaysRange, + toAnalyticsRange, + getAnalyticsOverview, + getAnalyticsStudents, + getAnalyticsRevenue, + getAnalyticsGradesByDay, + type StudentStat, + type AnalyticsRevenueResponse, +} from '@/api/analytics'; +import { + DashboardLayout, + Panel, + SectionHeader, + ListRow, +} from '@/components/dashboard/ui'; +import { RevenueChart } from '@/components/dashboard/RevenueChart'; +import type { IncomeChartData } from '@/api/dashboard'; +import { DateRangePicker } from '@/components/common/DateRangePicker'; +import { KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material'; +import type { Swiper as SwiperType } from 'swiper'; + +const ApexChart = dynamic(() => import('react-apexcharts'), { ssr: false }); +import type { ApexOptions } from 'apexcharts'; + +const navButtonStyle: React.CSSProperties = { + width: 40, + height: 40, + padding: 0, + borderRadius: 14, + border: 'none', + background: 'var(--md-sys-color-primary)', + color: 'var(--md-sys-color-on-primary)', + fontSize: 20, + cursor: 'pointer', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + boxShadow: 'var(--ios26-shadow-soft)', + transition: 'opacity 0.2s ease, box-shadow 0.2s ease', +}; + +type DateRange = { start_date: string; end_date: string }; + +const formatCurrency = (v: number) => + new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v); + +export default function AnalyticsPage() { + const { user } = useAuth(); + const chartHeight = useAnalyticsChartHeight(0.8); + const defaultRange = getLast30DaysRange(); + + const [rangeIncome, setRangeIncome] = useState(() => defaultRange); + const [rangeLessons, setRangeLessons] = useState(() => defaultRange); + const [rangeSuccess, setRangeSuccess] = useState(() => defaultRange); + + const [incomeData, setIncomeData] = useState(null); + const [incomeLoading, setIncomeLoading] = useState(true); + const [overviewLessons, setOverviewLessons] = useState(null); + const [revenueLessons, setRevenueLessons] = useState(null); + const [incomeForLessons, setIncomeForLessons] = useState<{ chart_data: { date: string; income: number; lessons: number }[] } | null>(null); + const [lessonsLoading, setLessonsLoading] = useState(true); + const [studentsSuccess, setStudentsSuccess] = useState<{ students: StudentStat[] } | null>(null); + const [overviewSuccess, setOverviewSuccess] = useState(null); + const [gradesByDaySuccess, setGradesByDaySuccess] = useState<{ + by_day: { date: string; average_grade: number | null; lessons_count: number; graded_count: number }[]; + summary: { total_lessons: number; graded_lessons: number; average_grade: number }; + } | null>(null); + const [successLoading, setSuccessLoading] = useState(true); + const [swiperInstance, setSwiperInstance] = useState(null); + + const loadIncome = useCallback(async () => { + if (user?.role !== 'mentor') return; + setIncomeLoading(true); + try { + const d = await getIncomeStats({ + period: 'range', + start_date: rangeIncome.start_date, + end_date: rangeIncome.end_date, + }); + setIncomeData(d); + } catch { + setIncomeData(null); + } finally { + setIncomeLoading(false); + } + }, [user?.role, rangeIncome]); + + const loadLessons = useCallback(async () => { + if (user?.role !== 'mentor') return; + setLessonsLoading(true); + try { + const r = toAnalyticsRange(rangeLessons); + const [ov, rev, income] = await Promise.all([ + getAnalyticsOverview(r).catch(() => null), + getAnalyticsRevenue(r).catch(() => null), + getIncomeStats({ period: 'range', start_date: rangeLessons.start_date, end_date: rangeLessons.end_date }).catch(() => null), + ]); + setOverviewLessons(ov); + setRevenueLessons(rev); + setIncomeForLessons(income ?? null); + } catch { + setOverviewLessons(null); + setRevenueLessons(null); + setIncomeForLessons(null); + } finally { + setLessonsLoading(false); + } + }, [user?.role, rangeLessons]); + + const loadSuccess = useCallback(async () => { + if (user?.role !== 'mentor') return; + setSuccessLoading(true); + try { + const r = toAnalyticsRange(rangeSuccess); + const [stu, ov, grades] = await Promise.all([ + getAnalyticsStudents(r).catch(() => null), + getAnalyticsOverview(r).catch(() => null), + getAnalyticsGradesByDay(r).catch(() => null), + ]); + setStudentsSuccess(stu ? { students: stu.students } : null); + setOverviewSuccess(ov); + setGradesByDaySuccess(grades ? { by_day: grades.by_day, summary: grades.summary } : null); + } catch { + setStudentsSuccess(null); + setOverviewSuccess(null); + setGradesByDaySuccess(null); + } finally { + setSuccessLoading(false); + } + }, [user?.role, rangeSuccess]); + + useEffect(() => { loadIncome(); }, [loadIncome]); + useEffect(() => { loadLessons(); }, [loadLessons]); + useEffect(() => { loadSuccess(); }, [loadSuccess]); + + if (user?.role !== 'mentor') { + return ( +
+ Аналитика доступна только менторам. +
+ ); + } + + const incomeChartData: IncomeChartData[] = useMemo( + () => (incomeData?.chart_data ?? []).map((d: { date: string; income: number; lessons: number }) => ({ + date: d.date, + income: d.income, + lessons: d.lessons, + })), + [incomeData?.chart_data], + ); + + return ( + +
+ + + {/* Доход */} + + } + /> + {incomeLoading && !incomeData ? ( +
+ +
+ ) : ( +
+
+ +
+
+
+
Общий доход
+
+ {incomeData?.summary ? formatCurrency(incomeData.summary.total_income || 0) : '—'} +
+
+
+
Средняя цена
+
+ {incomeData?.summary ? formatCurrency(incomeData.summary.average_lesson_price || 0) : '—'} +
+
+
+
+ )} +
+
+ + + {/* Занятия */} + + } + /> + {lessonsLoading && !revenueLessons ? ( +
+ +
+ ) : ( +
+
+ {(() => { + const byDay = revenueLessons?.by_day?.length + ? revenueLessons.by_day + : incomeForLessons?.chart_data?.map((d) => ({ date: d.date, revenue: d.income, lessons_count: d.lessons })) ?? []; + return byDay.length ? ( + + ) : ( +
+ Нет данных за период +
+ ); + })()} +
+
+
+
Всего
+
+ {overviewLessons?.lessons?.total ?? '—'} +
+
+
+
Проведено
+
+ {overviewLessons?.lessons?.completed ?? '—'} +
+
+
+
Отменено
+
+ {overviewLessons?.lessons?.cancelled ?? '—'} +
+
+
+
+ )} +
+
+ + + {/* Успех учеников — средняя оценка по дням, продуктивность репетитора */} + + } + /> + {successLoading && !gradesByDaySuccess ? ( +
+ +
+ ) : ( +
+
+ {gradesByDaySuccess?.by_day?.length ? ( + + ) : ( +
+ Нет данных за период +
+ )} +
+
+
+
Средняя оценка
+
{gradesByDaySuccess?.summary?.average_grade ?? overviewSuccess?.grades?.average ?? '—'}
+
+
+
Занятий с оценкой
+
{gradesByDaySuccess?.summary?.graded_lessons ?? '—'}
+
+
+
Активных учеников
+
{overviewSuccess?.students?.active ?? '—'}
+
+
+
+ )} +
+
+ + + {/* Топ занятий по доходам + Топ ученики — одна панель в 2 колонки */} + +
+
+ } /> +
+ {(() => { + const topLessons = (incomeData?.top_lessons ?? []).slice(0, 10); + if (!topLessons.length) { + return

Нет данных за период

; + } + return topLessons.map((item: any, i: number) => ( + + )); + })()} +
+
+
+ } /> + {studentsSuccess?.students?.length ? ( +
+ {studentsSuccess.students.slice(0, 10).map((s, i) => ( + + ))} +
+ ) : ( +

Нет данных

+ )} +
+
+
+
+
+
+ + +
+
+
+ ); +} + +function LessonsByDayChart({ byDay, height = 250 }: { byDay: { date: string; revenue: number; lessons_count: number }[]; height?: number }) { + const categories = useMemo(() => byDay.map((d) => d.date), [byDay]); + const series = useMemo(() => [{ name: 'Занятий', data: byDay.map((d) => d.lessons_count) }], [byDay]); + const options = useMemo( + () => ({ + chart: { + id: 'lessons-by-day', + toolbar: { + show: true, + tools: { zoomin: true, zoomout: true, pan: true, reset: true }, + autoSelected: 'pan' as const, + }, + zoom: { enabled: true, type: 'x' as const, allowMouseWheelZoom: true }, + pan: { enabled: true, type: 'x' as const }, + }, + stroke: { curve: 'smooth' as const, width: 2 }, + colors: ['var(--md-sys-color-primary)'], + dataLabels: { enabled: false }, + xaxis: { + categories, + tickPlacement: 'on', + axisBorder: { show: false }, + axisTicks: { show: false }, + labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, + }, + yaxis: { + labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, + }, + fill: { type: 'gradient' as const, gradient: { shadeIntensity: 0.5, opacityFrom: 0.5, opacityTo: 0.1 } }, + grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, + tooltip: { y: { formatter: (val: number) => `${val} занятий` } }, + }), + [categories], + ); + return ( +
+ +
+ ); +} + +function GradesByDayChart({ + byDay, + height = 250, +}: { + byDay: { date: string; average_grade: number | null; lessons_count: number; graded_count: number }[]; + height?: number; +}) { + const categories = useMemo(() => byDay.map((d) => d.date), [byDay]); + const series = useMemo( + () => [{ name: 'Средняя оценка', data: byDay.map((d) => d.average_grade ?? 0) }], + [byDay], + ); + const options = useMemo( + () => ({ + chart: { + id: 'grades-by-day', + toolbar: { + show: true, + tools: { zoomin: true, zoomout: true, pan: true, reset: true }, + autoSelected: 'pan' as const, + }, + zoom: { + enabled: true, + type: 'x' as const, + autoScaleYaxis: false, + allowMouseWheelZoom: true, + }, + pan: { enabled: true, type: 'x' as const }, + }, + stroke: { curve: 'smooth' as const, width: 2 }, + colors: ['var(--md-sys-color-primary)'], + dataLabels: { enabled: false }, + xaxis: { + categories, + tickPlacement: 'on', + axisBorder: { show: false }, + axisTicks: { show: false }, + labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, + }, + yaxis: { + min: 0, + max: 5, + labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, + }, + fill: { type: 'gradient' as const, gradient: { shadeIntensity: 0.5, opacityFrom: 0.5, opacityTo: 0.1 } }, + grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, + tooltip: { + y: { formatter: (val: number) => (val ? `Ср. оценка: ${val}` : '—') }, + }, + }), + [categories], + ); + return ( +
+ +
+ ); +} + +function StudentSuccessChart({ students }: { students: StudentStat[] }) { + const categories = useMemo(() => students.map((s) => s.name.length > 12 ? s.name.slice(0, 10) + '…' : s.name), [students]); + const series = useMemo(() => [{ name: 'Средняя оценка', data: students.map((s) => Number(s.average_grade) || 0) }], [students]); + const options = useMemo( + () => ({ + chart: { id: 'student-success', type: 'bar', toolbar: { show: false } }, + plotOptions: { bar: { borderRadius: 6, horizontal: false, columnWidth: '60%' } }, + colors: ['var(--md-sys-color-primary)'], + dataLabels: { enabled: false }, + xaxis: { + categories, + labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '11px' }, maxWidth: 100 }, + }, + yaxis: { + min: 0, + max: 5, + labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } }, + }, + grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 }, + tooltip: { y: { formatter: (val: number) => `Оценка: ${val}` } }, + }), + [categories], + ); + return ( +
+ +
+ ); +} diff --git a/front_material/app/(protected)/dashboard/page.tsx b/front_material/app/(protected)/dashboard/page.tsx index 1c0cd91..c1ebf1a 100644 --- a/front_material/app/(protected)/dashboard/page.tsx +++ b/front_material/app/(protected)/dashboard/page.tsx @@ -12,17 +12,7 @@ export default function DashboardPage() { const { selectedChild, loading: childLoading, childrenList } = useSelectedChild(); if (authLoading) { - return ( -
- -
- ); + return ; } if (!user) { @@ -38,17 +28,7 @@ export default function DashboardPage() { // Родитель: те же страницы, что и студент — показываем дашборд выбранного ребёнка if (user.role === 'parent') { if (childLoading && childrenList.length === 0) { - return ( -
- -
- ); + return ; } if (childrenList.length === 0) { return ( diff --git a/front_material/app/(protected)/feedback/page.tsx b/front_material/app/(protected)/feedback/page.tsx index e043e2c..1ea8e69 100644 --- a/front_material/app/(protected)/feedback/page.tsx +++ b/front_material/app/(protected)/feedback/page.tsx @@ -1,269 +1,269 @@ -'use client'; - -import React, { useState, useEffect, useMemo } from 'react'; -import { useAuth } from '@/contexts/AuthContext'; -import { getLessons, type Lesson } from '@/api/schedule'; -import { FeedbackModal } from '@/components/schedule/FeedbackModal'; -import { DashboardLayout } from '@/components/dashboard/ui'; -import { LoadingSpinner } from '@/components/common/LoadingSpinner'; - -function getSubjectName(lesson: Lesson): string { - if (typeof lesson.subject === 'string') return lesson.subject; - if (lesson.subject && typeof lesson.subject === 'object' && 'name' in lesson.subject) { - return (lesson.subject as { name: string }).name; - } - return (lesson as { subject_name?: string }).subject_name || 'Занятие'; -} - -function formatDate(s: string) { - return new Date(s).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); -} - -function formatTime(s: string) { - return new Date(s).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); -} - -export default function FeedbackPage() { - const { user, loading: authLoading } = useAuth(); - const [lessons, setLessons] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selectedLesson, setSelectedLesson] = useState(null); - const [showModal, setShowModal] = useState(false); - - useEffect(() => { - if (user?.role !== 'mentor') return; - let cancelled = false; - (async () => { - try { - setLoading(true); - const res = await getLessons({ status: 'completed' }); - const list = Array.isArray(res) ? res : res?.results || []; - if (!cancelled) setLessons(list); - } catch (e) { - if (!cancelled) setError(e instanceof Error ? e.message : 'Ошибка загрузки'); - } finally { - if (!cancelled) setLoading(false); - } - })(); - return () => { cancelled = true; }; - }, [user?.role]); - - const studentLessons = useMemo( - () => lessons.filter((l) => !(l as { group?: number }).group), - [lessons] - ); - - const getFeedbackStatus = (l: Lesson) => { - const has = !!( - l.mentor_grade || - (l as { school_grade?: number }).school_grade || - (l.mentor_notes && l.mentor_notes.trim().length > 0) - ); - return has ? 'done' : 'todo'; - }; - - const todoLessons = studentLessons.filter((l) => getFeedbackStatus(l) === 'todo'); - const doneLessons = studentLessons.filter((l) => getFeedbackStatus(l) === 'done'); - - const openFeedback = (lesson: Lesson) => { - setSelectedLesson(lesson); - setShowModal(true); - }; - - const handleSuccess = async () => { - setSelectedLesson(null); - setShowModal(false); - try { - const res = await getLessons({ status: 'completed' }); - const list = Array.isArray(res) ? res : res?.results || []; - setLessons(list); - } catch { - // ignore - } - }; - - const loadLessons = async () => { - try { - const res = await getLessons({ status: 'completed' }); - const list = Array.isArray(res) ? res : res?.results || []; - setLessons(list); - } catch { - // ignore - } - }; - - if (authLoading) { - return ( -
- -
- ); - } - - if (user?.role !== 'mentor') { - return ( - -
- Страница доступна только менторам -
-
- ); - } - - const LessonCard = ({ - lesson, - onFill, - }: { - lesson: Lesson; - onFill: () => void; - }) => { - const clientName = - typeof lesson.client === 'object' && lesson.client?.user - ? `${lesson.client.user.first_name} ${lesson.client.user.last_name}` - : (lesson as { client_name?: string }).client_name || 'Студент'; - - return ( -
- - {getSubjectName(lesson)} - -

- {lesson.title} -

-
-
- person - {clientName} -
-
- calendar_today - {formatDate(lesson.start_time)} -
-
- schedule - {formatTime(lesson.start_time)} — {formatTime(lesson.end_time)} -
- {lesson.mentor_grade != null && ( -
- Оценка: {lesson.mentor_grade}/5 -
- )} -
- -
- ); - }; - - return ( - - {error && ( -
- {error} -
- )} - - {loading ? ( -
- -
- ) : ( -
-
-

- Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''} -

-
- {todoLessons.map((l) => ( - openFeedback(l)} /> - ))} - {todoLessons.length === 0 && ( -

- Нет занятий -

- )} -
-
-
-

- Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''} -

-
- {doneLessons.map((l) => ( - openFeedback(l)} /> - ))} - {doneLessons.length === 0 && ( -

- Нет занятий -

- )} -
-
-
- )} - - { - setShowModal(false); - setSelectedLesson(null); - }} - onSuccess={handleSuccess} - /> -
- ); -} +'use client'; + +import React, { useState, useEffect, useMemo } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { getLessons, type Lesson } from '@/api/schedule'; +import { FeedbackModal } from '@/components/schedule/FeedbackModal'; +import { DashboardLayout } from '@/components/dashboard/ui'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; + +function getSubjectName(lesson: Lesson): string { + if (typeof lesson.subject === 'string') return lesson.subject; + if (lesson.subject && typeof lesson.subject === 'object' && 'name' in lesson.subject) { + return (lesson.subject as { name: string }).name; + } + return (lesson as { subject_name?: string }).subject_name || 'Занятие'; +} + +function formatDate(s: string) { + return new Date(s).toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +function formatTime(s: string) { + return new Date(s).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); +} + +export default function FeedbackPage() { + const { user, loading: authLoading } = useAuth(); + const [lessons, setLessons] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedLesson, setSelectedLesson] = useState(null); + const [showModal, setShowModal] = useState(false); + + useEffect(() => { + if (user?.role !== 'mentor') return; + let cancelled = false; + (async () => { + try { + setLoading(true); + const res = await getLessons({ status: 'completed' }); + const list = Array.isArray(res) ? res : res?.results || []; + if (!cancelled) setLessons(list); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : 'Ошибка загрузки'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { cancelled = true; }; + }, [user?.role]); + + const studentLessons = useMemo( + () => lessons.filter((l) => !(l as { group?: number }).group), + [lessons] + ); + + const getFeedbackStatus = (l: Lesson) => { + const has = !!( + l.mentor_grade || + (l as { school_grade?: number }).school_grade || + (l.mentor_notes && l.mentor_notes.trim().length > 0) + ); + return has ? 'done' : 'todo'; + }; + + const todoLessons = studentLessons.filter((l) => getFeedbackStatus(l) === 'todo'); + const doneLessons = studentLessons.filter((l) => getFeedbackStatus(l) === 'done'); + + const openFeedback = (lesson: Lesson) => { + setSelectedLesson(lesson); + setShowModal(true); + }; + + const handleSuccess = async () => { + setSelectedLesson(null); + setShowModal(false); + try { + const res = await getLessons({ status: 'completed' }); + const list = Array.isArray(res) ? res : res?.results || []; + setLessons(list); + } catch { + // ignore + } + }; + + const loadLessons = async () => { + try { + const res = await getLessons({ status: 'completed' }); + const list = Array.isArray(res) ? res : res?.results || []; + setLessons(list); + } catch { + // ignore + } + }; + + if (authLoading) { + return ( +
+ +
+ ); + } + + if (user?.role !== 'mentor') { + return ( + +
+ Страница доступна только менторам +
+
+ ); + } + + const LessonCard = ({ + lesson, + onFill, + }: { + lesson: Lesson; + onFill: () => void; + }) => { + const clientName = + typeof lesson.client === 'object' && lesson.client?.user + ? `${lesson.client.user.first_name} ${lesson.client.user.last_name}` + : (lesson as { client_name?: string }).client_name || 'Студент'; + + return ( +
+ + {getSubjectName(lesson)} + +

+ {lesson.title} +

+
+
+ person + {clientName} +
+
+ calendar_today + {formatDate(lesson.start_time)} +
+
+ schedule + {formatTime(lesson.start_time)} — {formatTime(lesson.end_time)} +
+ {lesson.mentor_grade != null && ( +
+ Оценка: {lesson.mentor_grade}/5 +
+ )} +
+ +
+ ); + }; + + return ( + + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ +
+ ) : ( +
+
+

+ Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''} +

+
+ {todoLessons.map((l) => ( + openFeedback(l)} /> + ))} + {todoLessons.length === 0 && ( +

+ Нет занятий +

+ )} +
+
+
+

+ Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''} +

+
+ {doneLessons.map((l) => ( + openFeedback(l)} /> + ))} + {doneLessons.length === 0 && ( +

+ Нет занятий +

+ )} +
+
+
+ )} + + { + setShowModal(false); + setSelectedLesson(null); + }} + onSuccess={handleSuccess} + /> +
+ ); +} diff --git a/front_material/app/(protected)/homework/page.tsx b/front_material/app/(protected)/homework/page.tsx index c4563fa..762d2bf 100644 --- a/front_material/app/(protected)/homework/page.tsx +++ b/front_material/app/(protected)/homework/page.tsx @@ -1,7 +1,7 @@ -'use client'; - -import { HomeworkPageContent } from '@/components/homework/HomeworkPageContent'; - -export default function HomeworkPage() { - return ; -} +'use client'; + +import { HomeworkPageContent } from '@/components/homework/HomeworkPageContent'; + +export default function HomeworkPage() { + return ; +} diff --git a/front_material/app/(protected)/layout.tsx b/front_material/app/(protected)/layout.tsx index 73622f3..ade0745 100644 --- a/front_material/app/(protected)/layout.tsx +++ b/front_material/app/(protected)/layout.tsx @@ -76,41 +76,57 @@ export default function ProtectedLayout({ useEffect(() => { // Проверяем токен в localStorage напрямую, чтобы избежать race condition - const token = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; + const accessToken = typeof window !== 'undefined' ? localStorage.getItem('access_token') : null; + const refreshToken = typeof window !== 'undefined' ? localStorage.getItem('refresh_token') : null; - console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname }); + console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasAccessToken: !!accessToken, hasRefreshToken: !!refreshToken, pathname }); if (!loading && !user) { - console.log('[ProtectedLayout] No user found, redirecting to login'); - router.replace('/login'); + // Если есть refresh токен, пробуем обновить сессию вместо редиректа + if (refreshToken) { + console.log('[ProtectedLayout] User lost but refresh token exists, trying to restore session...'); + import('@/api/auth').then(({ refreshToken: doRefresh }) => { + doRefresh(refreshToken) + .then(({ access }) => { + if (access) { + localStorage.setItem('access_token', access); + console.log('[ProtectedLayout] Session restored, reloading user...'); + window.location.reload(); + } else { + console.log('[ProtectedLayout] Refresh failed, redirecting to login'); + router.replace('/login'); + } + }) + .catch(() => { + console.log('[ProtectedLayout] Refresh error, redirecting to login'); + localStorage.removeItem('access_token'); + localStorage.removeItem('refresh_token'); + router.replace('/login'); + }); + }); + } else { + console.log('[ProtectedLayout] No user and no refresh token, redirecting to login'); + router.replace('/login'); + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, [user, loading]); - if (loading) { - return ( -
+ // Стабильный loading layout - предотвращает дёрганье + const loadingLayout = ( +
+
-
- ); + +
+ ); + + if (loading) { + return loadingLayout; } if (!user) { - return ( -
-
- -

Проверка авторизации...

-
-
- ); + return loadingLayout; } // Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом diff --git a/front_material/app/(protected)/payment/page.tsx b/front_material/app/(protected)/payment/page.tsx index 3756e2d..5a700a6 100644 --- a/front_material/app/(protected)/payment/page.tsx +++ b/front_material/app/(protected)/payment/page.tsx @@ -1,20 +1,20 @@ -'use client'; - -import dynamic from 'next/dynamic'; -import { LoadingSpinner } from '@/components/common/LoadingSpinner'; - -const ProfilePaymentTab = dynamic( - () => import('@/components/profile/ProfilePaymentTab').then((m) => ({ default: m.ProfilePaymentTab })), - { ssr: false, loading: () => } -); - -export default function PaymentPage() { - return ( -
-

- Подписки и оплата -

- -
- ); -} +'use client'; + +import dynamic from 'next/dynamic'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; + +const ProfilePaymentTab = dynamic( + () => import('@/components/profile/ProfilePaymentTab').then((m) => ({ default: m.ProfilePaymentTab })), + { ssr: false, loading: () => } +); + +export default function PaymentPage() { + return ( +
+

+ Подписки и оплата +

+ +
+ ); +} diff --git a/front_material/components/chat/ChatList.tsx b/front_material/components/chat/ChatList.tsx index 3218d42..6372c2d 100644 --- a/front_material/components/chat/ChatList.tsx +++ b/front_material/components/chat/ChatList.tsx @@ -1,268 +1,268 @@ -'use client'; - -import React from 'react'; -import { Box, TextField, List, ListItemButton, ListItemText, Badge, Avatar, Button, IconButton } from '@mui/material'; -import NotificationsActiveRoundedIcon from '@mui/icons-material/NotificationsActiveRounded'; -import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; -import AssignmentRoundedIcon from '@mui/icons-material/AssignmentRounded'; -import AddCommentRoundedIcon from '@mui/icons-material/AddCommentRounded'; -import type { Chat } from '@/api/chat'; -import { NewChatModal } from './NewChatModal'; - -interface ChatListProps { - chats: Chat[]; - selectedChatUuid: string | null; - onSelect: (chat: Chat) => void; - hasMore?: boolean; - loadingMore?: boolean; - onLoadMore?: () => void; -} - -function parsePreview(text: string) { - const t = (text || '').trim(); - if (!t) return { icons: [] as Array<'bell' | 'trash' | 'note'>, text: '—' }; - - const icons: Array<'bell' | 'trash' | 'note'> = []; - let rest = t; - - // выкусываем частые эмодзи-пиктограммы из system сообщений - const take = (emoji: string, key: 'bell' | 'trash' | 'note') => { - if (rest.includes(emoji)) { - icons.push(key); - rest = rest.replace(emoji, '').trim(); - } - }; - - take('🔔', 'bell'); - take('🗑️', 'trash'); - take('🗑', 'trash'); - take('📝', 'note'); - - // убираем лишние эмодзи/разделители в начале - rest = rest.replace(/^[-–—•\s]+/, '').trim(); - return { icons, text: rest || '—' }; -} - -export function ChatList({ chats, selectedChatUuid, onSelect, hasMore, loadingMore, onLoadMore }: ChatListProps) { - const [q, setQ] = React.useState(''); - const [isNewChatModalOpen, setIsNewChatModalOpen] = React.useState(false); - - const filtered = React.useMemo(() => { - const qq = q.trim().toLowerCase(); - if (!qq) return chats; - return chats.filter((c) => { - const name = (c.participant_name || '').toLowerCase(); - const last = (c.last_message || '').toLowerCase(); - return name.includes(qq) || last.includes(qq); - }); - }, [chats, q]); - - return ( - - - setQ(e.target.value)} - placeholder="Поиск" - size="small" - fullWidth - sx={{ - '& .MuiInputBase-root': { - borderRadius: 3, - backgroundColor: 'rgba(255,255,255,0.7)', - }, - }} - /> - setIsNewChatModalOpen(true)} - sx={{ - bgcolor: 'var(--md-sys-color-primary)', - color: 'var(--md-sys-color-on-primary)', - '&:hover': { bgcolor: 'var(--md-sys-color-primary)', filter: 'brightness(0.9)' } - }} - > - - - - - - - {filtered.map((chat) => { - const selected = !!selectedChatUuid && chat.uuid === selectedChatUuid; - const preview = parsePreview(chat.last_message || ''); - return ( - onSelect(chat)} - sx={{ - borderRadius: 2, - mb: 0.5, - minWidth: 0, - '&.Mui-selected': { - backgroundColor: 'rgba(116, 68, 253, 0.12)', - }, - }} - > - - - {(chat.participant_name || 'Ч') - .trim() - .split(/\s+/) - .slice(0, 2) - .map((p) => p[0]) - .join('') - .toUpperCase()} - - {!!(chat as any).other_is_online && ( - - )} - - - - {chat.participant_name || 'Чат'} - - {!!chat.unread_count && chat.unread_count > 0 && ( - - )} - - } - secondary={ - - {preview.icons.includes('bell') && ( - - )} - {preview.icons.includes('trash') && ( - - )} - {preview.icons.includes('note') && ( - - )} - - {preview.text} - - - } - sx={{ minWidth: 0 }} - primaryTypographyProps={{ component: 'span' }} - secondaryTypographyProps={{ component: 'span' }} - /> - - ); - })} - - {hasMore && onLoadMore && ( - - - - )} - - - setIsNewChatModalOpen(false)} - onChatCreated={(chat) => { - onSelect(chat); - // Можно добавить уведомление - }} - /> - - ); -} - +'use client'; + +import React from 'react'; +import { Box, TextField, List, ListItemButton, ListItemText, Badge, Avatar, Button, IconButton } from '@mui/material'; +import NotificationsActiveRoundedIcon from '@mui/icons-material/NotificationsActiveRounded'; +import DeleteRoundedIcon from '@mui/icons-material/DeleteRounded'; +import AssignmentRoundedIcon from '@mui/icons-material/AssignmentRounded'; +import AddCommentRoundedIcon from '@mui/icons-material/AddCommentRounded'; +import type { Chat } from '@/api/chat'; +import { NewChatModal } from './NewChatModal'; + +interface ChatListProps { + chats: Chat[]; + selectedChatUuid: string | null; + onSelect: (chat: Chat) => void; + hasMore?: boolean; + loadingMore?: boolean; + onLoadMore?: () => void; +} + +function parsePreview(text: string) { + const t = (text || '').trim(); + if (!t) return { icons: [] as Array<'bell' | 'trash' | 'note'>, text: '—' }; + + const icons: Array<'bell' | 'trash' | 'note'> = []; + let rest = t; + + // выкусываем частые эмодзи-пиктограммы из system сообщений + const take = (emoji: string, key: 'bell' | 'trash' | 'note') => { + if (rest.includes(emoji)) { + icons.push(key); + rest = rest.replace(emoji, '').trim(); + } + }; + + take('🔔', 'bell'); + take('🗑️', 'trash'); + take('🗑', 'trash'); + take('📝', 'note'); + + // убираем лишние эмодзи/разделители в начале + rest = rest.replace(/^[-–—•\s]+/, '').trim(); + return { icons, text: rest || '—' }; +} + +export function ChatList({ chats, selectedChatUuid, onSelect, hasMore, loadingMore, onLoadMore }: ChatListProps) { + const [q, setQ] = React.useState(''); + const [isNewChatModalOpen, setIsNewChatModalOpen] = React.useState(false); + + const filtered = React.useMemo(() => { + const qq = q.trim().toLowerCase(); + if (!qq) return chats; + return chats.filter((c) => { + const name = (c.participant_name || '').toLowerCase(); + const last = (c.last_message || '').toLowerCase(); + return name.includes(qq) || last.includes(qq); + }); + }, [chats, q]); + + return ( + + + setQ(e.target.value)} + placeholder="Поиск" + size="small" + fullWidth + sx={{ + '& .MuiInputBase-root': { + borderRadius: 3, + backgroundColor: 'rgba(255,255,255,0.7)', + }, + }} + /> + setIsNewChatModalOpen(true)} + sx={{ + bgcolor: 'var(--md-sys-color-primary)', + color: 'var(--md-sys-color-on-primary)', + '&:hover': { bgcolor: 'var(--md-sys-color-primary)', filter: 'brightness(0.9)' } + }} + > + + + + + + + {filtered.map((chat) => { + const selected = !!selectedChatUuid && chat.uuid === selectedChatUuid; + const preview = parsePreview(chat.last_message || ''); + return ( + onSelect(chat)} + sx={{ + borderRadius: 2, + mb: 0.5, + minWidth: 0, + '&.Mui-selected': { + backgroundColor: 'rgba(116, 68, 253, 0.12)', + }, + }} + > + + + {(chat.participant_name || 'Ч') + .trim() + .split(/\s+/) + .slice(0, 2) + .map((p) => p[0]) + .join('') + .toUpperCase()} + + {!!(chat as any).other_is_online && ( + + )} + + + + {chat.participant_name || 'Чат'} + + {!!chat.unread_count && chat.unread_count > 0 && ( + + )} + + } + secondary={ + + {preview.icons.includes('bell') && ( + + )} + {preview.icons.includes('trash') && ( + + )} + {preview.icons.includes('note') && ( + + )} + + {preview.text} + + + } + sx={{ minWidth: 0 }} + primaryTypographyProps={{ component: 'span' }} + secondaryTypographyProps={{ component: 'span' }} + /> + + ); + })} + + {hasMore && onLoadMore && ( + + + + )} + + + setIsNewChatModalOpen(false)} + onChatCreated={(chat) => { + onSelect(chat); + // Можно добавить уведомление + }} + /> + + ); +} + diff --git a/front_material/components/common/LoadingSpinner.tsx b/front_material/components/common/LoadingSpinner.tsx index 88e90a5..41d2933 100644 --- a/front_material/components/common/LoadingSpinner.tsx +++ b/front_material/components/common/LoadingSpinner.tsx @@ -9,10 +9,18 @@ const sizeMap = { large: '64px', } as const; +interface LoadingSpinnerProps { + size?: 'small' | 'medium' | 'large'; + inline?: boolean; + /** Занимает всё доступное пространство страницы - предотвращает layout shift */ + fullPage?: boolean; +} + export function LoadingSpinner({ size = 'medium', inline = false, -}: { size?: 'small' | 'medium' | 'large'; inline?: boolean }) { + fullPage = false, +}: LoadingSpinnerProps) { const [mounted, setMounted] = useState(false); const [componentsLoaded, setComponentsLoaded] = useState(false); @@ -23,6 +31,18 @@ export function LoadingSpinner({ }); }, []); + // Стили для fullPage режима - занимает всё пространство, предотвращает дёрганье + const fullPageStyle: React.CSSProperties = fullPage ? { + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + width: '100%', + height: '100%', + minHeight: '100%', + flex: 1, + background: 'var(--md-sys-color-background)', + } : {}; + if (!mounted || !componentsLoaded) { if (inline) { return ( @@ -34,6 +54,18 @@ export function LoadingSpinner({ ); } + if (fullPage) { + return ( +
+ + progress_activity + +
+ ); + } return
Загрузка...
; } @@ -43,6 +75,7 @@ export function LoadingSpinner({ display: 'flex', justifyContent: 'center', alignItems: 'center', + ...(fullPage ? fullPageStyle : {}), ...(inline ? { padding: 0, width: sizeMap[size], height: sizeMap[size] } : { padding: '20px' }), }} > diff --git a/front_material/components/dashboard/LessonCard.tsx b/front_material/components/dashboard/LessonCard.tsx index 495536e..0ee2610 100644 --- a/front_material/components/dashboard/LessonCard.tsx +++ b/front_material/components/dashboard/LessonCard.tsx @@ -41,10 +41,11 @@ export const LessonCard: React.FC = ({ const [connectLoading, setConnectLoading] = useState(false); const [canJoin, setCanJoin] = useState(false); + // Проверяем каждые 10 секунд, чтобы кнопка появилась вовремя (решение проблемы с кэшированием) useEffect(() => { const check = () => setCanJoin(canJoinLesson(lesson)); check(); - const interval = setInterval(check, 60000); + const interval = setInterval(check, 10000); // 10 секунд return () => clearInterval(interval); }, [lesson.start_time, lesson.end_time, lesson.status]); diff --git a/front_material/components/dashboard/mentor/ExtraStatsSection.tsx b/front_material/components/dashboard/mentor/ExtraStatsSection.tsx index 679760c..3dafabc 100644 --- a/front_material/components/dashboard/mentor/ExtraStatsSection.tsx +++ b/front_material/components/dashboard/mentor/ExtraStatsSection.tsx @@ -1,167 +1,167 @@ -/** - * Секция «Статистика» (список) для дашборда ментора (iOS 26). - */ - -'use client'; - -import React from 'react'; -import { MentorDashboardResponse } from '@/api/dashboard'; -import { Panel, SectionHeader } from '../ui'; -import type { StatsListRow } from '../ui'; - -export interface ExtraStatsSectionProps { - stats: MentorDashboardResponse | null; - loading: boolean; -} - -const IconUsers = ( - - - - -); - -const IconCheck = ( - - - -); - -const IconCalendar = ( - - - - - - -); - -const IconRevenue = ( - - - - -); - -const IconFile = ( - - - - -); - -const IconClipboard = ( - - - - -); - -const IconTrendingUp = ( - - - - -); - -function buildRows(stats: MentorDashboardResponse | null, loading: boolean): StatsListRow[] { - const s = stats?.summary; - if (loading || !s) { - return [ - { label: 'Всего учеников:', value: '—', icon: IconUsers }, - { label: 'Завершённых занятий всего:', value: '—', icon: IconCheck }, - { label: 'Занятий на этой неделе:', value: '—', icon: IconCalendar }, - { label: 'Всего занятий (месяц / всего):', value: '—', icon: IconCalendar }, - { label: 'Доход (месяц / всё время):', value: '—', icon: IconRevenue, highlight: 'tertiary' }, - { label: 'Средняя цена занятия:', value: '—', icon: IconTrendingUp }, - { label: 'Процент завершённых:', value: '—', icon: IconTrendingUp }, - { label: 'ДЗ на проверке:', value: '—', icon: IconClipboard }, - { label: 'Всего материалов:', value: '—', icon: IconFile }, - ]; - } - - // Вычисляем среднюю цену занятия - const averagePrice = - s.completed_lessons && s.completed_lessons > 0 && s.total_revenue - ? Math.round(s.total_revenue / s.completed_lessons) - : null; - - // Вычисляем процент завершенных занятий - const completionRate = - s.total_lessons && s.total_lessons > 0 && s.completed_lessons - ? Math.round((s.completed_lessons / s.total_lessons) * 100) - : null; - - return [ - { label: 'Всего учеников:', value: s.total_clients ?? '—', icon: IconUsers }, - { label: 'Завершённых занятий всего:', value: s.completed_lessons ?? '—', icon: IconCheck }, - { label: 'Занятий на этой неделе:', value: s.lessons_this_week ?? '—', icon: IconCalendar }, - { - label: 'Всего занятий (месяц / всего):', - value: `${s.lessons_this_month ?? 0}/${s.total_lessons ?? 0}`, - icon: IconCalendar, - }, - { - label: 'Доход (месяц / всё время):', - value: - s.revenue_this_month != null || s.total_revenue != null - ? `${s.revenue_this_month != null ? `${Math.round(s.revenue_this_month).toLocaleString('ru-RU')} ₽` : '0 ₽'} / ${ - s.total_revenue != null ? `${Math.round(s.total_revenue).toLocaleString('ru-RU')} ₽` : '0 ₽' - }` - : '—', - icon: IconRevenue, - highlight: 'tertiary', - }, - { - label: 'Средняя цена занятия:', - value: averagePrice ? `${averagePrice.toLocaleString('ru-RU')} ₽` : '—', - icon: IconTrendingUp, - }, - { - label: 'Процент завершённых:', - value: completionRate != null ? `${completionRate}%` : '—', - icon: IconTrendingUp, - }, - { - label: 'ДЗ на проверке:', - value: s.pending_submissions ?? '—', - icon: IconClipboard, - highlight: s.pending_submissions && s.pending_submissions > 0 ? 'error' : undefined, - }, - { - label: 'Всего материалов:', - value: s.total_materials ?? '—', - icon: IconFile, - }, - ]; -} - -export const ExtraStatsSection: React.FC = ({ stats, loading }) => { - const rows = buildRows(stats, loading).slice(0, 9); - - return ( - - -
- {rows.map((row, index) => { - const highlightClass = - row.highlight === 'tertiary' - ? ' ios26-stat-value--tertiary' - : row.highlight === 'error' - ? ' ios26-stat-value--error' - : row.highlight === 'primary' || row.highlight === true - ? ' ios26-stat-value--primary' - : ''; - - return ( -
- {row.icon &&
{row.icon}
} -
{row.label.replace(/:$/, '')}
-
{row.value}
-
- ); - })} -
-
- ); -}; +/** + * Секция «Статистика» (список) для дашборда ментора (iOS 26). + */ + +'use client'; + +import React from 'react'; +import { MentorDashboardResponse } from '@/api/dashboard'; +import { Panel, SectionHeader } from '../ui'; +import type { StatsListRow } from '../ui'; + +export interface ExtraStatsSectionProps { + stats: MentorDashboardResponse | null; + loading: boolean; +} + +const IconUsers = ( + + + + +); + +const IconCheck = ( + + + +); + +const IconCalendar = ( + + + + + + +); + +const IconRevenue = ( + + + + +); + +const IconFile = ( + + + + +); + +const IconClipboard = ( + + + + +); + +const IconTrendingUp = ( + + + + +); + +function buildRows(stats: MentorDashboardResponse | null, loading: boolean): StatsListRow[] { + const s = stats?.summary; + if (loading || !s) { + return [ + { label: 'Всего учеников:', value: '—', icon: IconUsers }, + { label: 'Завершённых занятий всего:', value: '—', icon: IconCheck }, + { label: 'Занятий на этой неделе:', value: '—', icon: IconCalendar }, + { label: 'Всего занятий (месяц / всего):', value: '—', icon: IconCalendar }, + { label: 'Доход (месяц / всё время):', value: '—', icon: IconRevenue, highlight: 'tertiary' }, + { label: 'Средняя цена занятия:', value: '—', icon: IconTrendingUp }, + { label: 'Процент завершённых:', value: '—', icon: IconTrendingUp }, + { label: 'ДЗ на проверке:', value: '—', icon: IconClipboard }, + { label: 'Всего материалов:', value: '—', icon: IconFile }, + ]; + } + + // Вычисляем среднюю цену занятия + const averagePrice = + s.completed_lessons && s.completed_lessons > 0 && s.total_revenue + ? Math.round(s.total_revenue / s.completed_lessons) + : null; + + // Вычисляем процент завершенных занятий + const completionRate = + s.total_lessons && s.total_lessons > 0 && s.completed_lessons + ? Math.round((s.completed_lessons / s.total_lessons) * 100) + : null; + + return [ + { label: 'Всего учеников:', value: s.total_clients ?? '—', icon: IconUsers }, + { label: 'Завершённых занятий всего:', value: s.completed_lessons ?? '—', icon: IconCheck }, + { label: 'Занятий на этой неделе:', value: s.lessons_this_week ?? '—', icon: IconCalendar }, + { + label: 'Всего занятий (месяц / всего):', + value: `${s.lessons_this_month ?? 0}/${s.total_lessons ?? 0}`, + icon: IconCalendar, + }, + { + label: 'Доход (месяц / всё время):', + value: + s.revenue_this_month != null || s.total_revenue != null + ? `${s.revenue_this_month != null ? `${Math.round(s.revenue_this_month).toLocaleString('ru-RU')} ₽` : '0 ₽'} / ${ + s.total_revenue != null ? `${Math.round(s.total_revenue).toLocaleString('ru-RU')} ₽` : '0 ₽' + }` + : '—', + icon: IconRevenue, + highlight: 'tertiary', + }, + { + label: 'Средняя цена занятия:', + value: averagePrice ? `${averagePrice.toLocaleString('ru-RU')} ₽` : '—', + icon: IconTrendingUp, + }, + { + label: 'Процент завершённых:', + value: completionRate != null ? `${completionRate}%` : '—', + icon: IconTrendingUp, + }, + { + label: 'ДЗ на проверке:', + value: s.pending_submissions ?? '—', + icon: IconClipboard, + highlight: s.pending_submissions && s.pending_submissions > 0 ? 'error' : undefined, + }, + { + label: 'Всего материалов:', + value: s.total_materials ?? '—', + icon: IconFile, + }, + ]; +} + +export const ExtraStatsSection: React.FC = ({ stats, loading }) => { + const rows = buildRows(stats, loading).slice(0, 9); + + return ( + + +
+ {rows.map((row, index) => { + const highlightClass = + row.highlight === 'tertiary' + ? ' ios26-stat-value--tertiary' + : row.highlight === 'error' + ? ' ios26-stat-value--error' + : row.highlight === 'primary' || row.highlight === true + ? ' ios26-stat-value--primary' + : ''; + + return ( +
+ {row.icon &&
{row.icon}
} +
{row.label.replace(/:$/, '')}
+
{row.value}
+
+ ); + })} +
+
+ ); +}; diff --git a/front_material/components/dashboard/ui/DashboardLayout.tsx b/front_material/components/dashboard/ui/DashboardLayout.tsx index 0dfe20a..e704586 100644 --- a/front_material/components/dashboard/ui/DashboardLayout.tsx +++ b/front_material/components/dashboard/ui/DashboardLayout.tsx @@ -1,24 +1,24 @@ -/** - * Общая обёртка страницы дашборда. - * Переиспользуется для ментора, клиента, родителя и др. ролей. - */ - -'use client'; - -import React from 'react'; - -export interface DashboardLayoutProps { - children: React.ReactNode; - /** Дополнительный класс для контейнера */ - className?: string; - /** data-tour для онбординга */ - 'data-tour'?: string; -} - -export const DashboardLayout: React.FC = ({ children, className = '', 'data-tour': dataTour }) => { - return ( -
- {children} -
- ); -}; +/** + * Общая обёртка страницы дашборда. + * Переиспользуется для ментора, клиента, родителя и др. ролей. + */ + +'use client'; + +import React from 'react'; + +export interface DashboardLayoutProps { + children: React.ReactNode; + /** Дополнительный класс для контейнера */ + className?: string; + /** data-tour для онбординга */ + 'data-tour'?: string; +} + +export const DashboardLayout: React.FC = ({ children, className = '', 'data-tour': dataTour }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/front_material/components/dashboard/ui/Panel.tsx b/front_material/components/dashboard/ui/Panel.tsx index 431707d..c5961d5 100644 --- a/front_material/components/dashboard/ui/Panel.tsx +++ b/front_material/components/dashboard/ui/Panel.tsx @@ -1,51 +1,51 @@ -/** - * Стеклянная панель в стиле iOS 26. - * Переиспользуется для блоков статистики, графиков, списков. - */ - -'use client'; - -import React from 'react'; - -export interface PanelProps { - children: React.ReactNode; - /** Дополнительный класс */ - className?: string; - /** Включить hover-эффект (тень) */ - interactive?: boolean; - /** Внутренние отступы. По умолчанию 24px */ - padding?: 'none' | 'sm' | 'md' | 'lg'; - style?: React.CSSProperties; - /** Атрибут для онбординга (data-tour) */ - 'data-tour'?: string; -} - -const paddingMap = { - none: 0, - sm: 16, - md: 24, - lg: 32, -}; - -export const Panel: React.FC = ({ - children, - className = '', - interactive = false, - padding = 'md', - style, - 'data-tour': dataTour, -}) => { - const p = paddingMap[padding]; - return ( -
- {children} -
- ); -}; +/** + * Стеклянная панель в стиле iOS 26. + * Переиспользуется для блоков статистики, графиков, списков. + */ + +'use client'; + +import React from 'react'; + +export interface PanelProps { + children: React.ReactNode; + /** Дополнительный класс */ + className?: string; + /** Включить hover-эффект (тень) */ + interactive?: boolean; + /** Внутренние отступы. По умолчанию 24px */ + padding?: 'none' | 'sm' | 'md' | 'lg'; + style?: React.CSSProperties; + /** Атрибут для онбординга (data-tour) */ + 'data-tour'?: string; +} + +const paddingMap = { + none: 0, + sm: 16, + md: 24, + lg: 32, +}; + +export const Panel: React.FC = ({ + children, + className = '', + interactive = false, + padding = 'md', + style, + 'data-tour': dataTour, +}) => { + const p = paddingMap[padding]; + return ( +
+ {children} +
+ ); +}; diff --git a/front_material/components/homework/HomeworkPageContent.tsx b/front_material/components/homework/HomeworkPageContent.tsx index f188ab2..4771c5d 100644 --- a/front_material/components/homework/HomeworkPageContent.tsx +++ b/front_material/components/homework/HomeworkPageContent.tsx @@ -1,291 +1,291 @@ -'use client'; - -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { useAuth } from '@/contexts/AuthContext'; -import { useSelectedChild } from '@/contexts/SelectedChildContext'; -import { - getHomework, - getHomeworkById, - type Homework, -} from '@/api/homework'; -import { HomeworkDetailsModal } from './HomeworkDetailsModal'; -import { SubmitHomeworkModal } from './SubmitHomeworkModal'; -import { DashboardLayout } from '@/components/dashboard/ui'; -import { LoadingSpinner } from '@/components/common/LoadingSpinner'; - -function formatDate(s: string | null): string { - if (!s) return '—'; - const d = new Date(s); - return isNaN(d.getTime()) ? '—' : d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); -} - -function formatTime(s: string | null): string { - if (!s) return '—'; - const d = new Date(s); - return isNaN(d.getTime()) ? '—' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); -} - -function getHomeworkStatus(hw: Homework): 'pending' | 'submitted' | 'returned' | 'reviewed' { - if (hw.status !== 'published') return 'pending'; - if (hw.checked_submissions > 0 && hw.checked_submissions === hw.total_submissions) return 'reviewed'; - if (hw.returned_submissions > 0 && hw.returned_submissions === hw.total_submissions) return 'returned'; - if (hw.total_submissions > 0) return 'submitted'; - return 'pending'; -} - -export function HomeworkPageContent() { - const { user } = useAuth(); - const { selectedChild } = useSelectedChild(); - const [homework, setHomework] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [selectedHomework, setSelectedHomework] = useState(null); - const [detailsOpen, setDetailsOpen] = useState(false); - const [detailsLoading, setDetailsLoading] = useState(false); - const [submitId, setSubmitId] = useState(null); - const [submitOpen, setSubmitOpen] = useState(false); - - const loadHomework = useCallback(async () => { - try { - setLoading(true); - const res = await getHomework({ - page_size: 1000, - ...(user?.role === 'parent' && selectedChild?.id && { child_id: selectedChild.id }), - }); - setHomework(res.results); - } catch (e) { - setError(e instanceof Error ? e.message : 'Ошибка загрузки'); - } finally { - setLoading(false); - } - }, [user?.role, selectedChild?.id]); - - useEffect(() => { - loadHomework(); - }, [loadHomework]); - - const userRole = user?.role ?? ''; - - const pending = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'pending' && hw.status === 'published'), [homework]); - const submitted = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'submitted'), [homework]); - const returned = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'returned'), [homework]); - const reviewed = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'reviewed'), [homework]); - /** Только для ментора: черновики «заполнить позже» — ожидают заполнения задания. */ - const fillLater = useMemo( - () => (userRole === 'mentor' ? homework.filter((hw) => hw.fill_later === true) : []), - [homework, userRole] - ); - /** Только для ментора: задания, у которых есть хотя бы одно решение с черновиком от ИИ. */ - const aiDraft = useMemo( - () => (userRole === 'mentor' ? homework.filter((hw) => (hw.ai_draft_count ?? 0) > 0) : []), - [homework, userRole] - ); - - const handleViewDetails = useCallback(async (hw: Homework) => { - try { - setDetailsLoading(true); - const full = await getHomeworkById(hw.id); - setSelectedHomework(full); - setDetailsOpen(true); - } catch { - setSelectedHomework(hw); - setDetailsOpen(true); - } finally { - setDetailsLoading(false); - } - }, []); - - const handleSubmit = useCallback((hw: Homework) => { - setSubmitId(hw.id); - setSubmitOpen(true); - }, []); - - const HomeworkCard = ({ - hw, - badge, - onView, - onSubmit, - }: { - hw: Homework; - badge: string; - onView: () => void; - onSubmit?: () => void; - }) => ( -
-
- - {badge} - -
-

- {hw.title} -

-
- {userRole === 'client' && ( -
- person - {hw.mentor.first_name} {hw.mentor.last_name} -
- )} - {userRole === 'mentor' && hw.total_submissions > 0 && ( -
Решений: {hw.total_submissions}
- )} - {hw.deadline && ( - <> -
- calendar_today - {formatDate(hw.deadline)} {formatTime(hw.deadline)} -
- - )} - {hw.student_score?.score != null && ( -
- Оценка: {hw.student_score.score} / 5 -
- )} -
- {userRole === 'client' && onSubmit && getHomeworkStatus(hw) === 'pending' && ( - - )} -
- ); - - const Column = ({ - title, - count, - items, - getBadge, - }: { - title: string; - count: number; - items: Homework[]; - getBadge: (hw: Homework) => string; - }) => ( -
-

- {title} {count > 0 ? `(${count})` : ''} -

-
- {items.map((hw) => ( - handleViewDetails(hw)} - onSubmit={userRole === 'client' ? () => handleSubmit(hw) : undefined} - /> - ))} - {items.length === 0 && ( -

- Нет заданий -

- )} -
-
- ); - - return ( - - {error && ( -
- {error} -
- )} - - {loading ? ( -
- -
- ) : homework.length === 0 ? ( -

- Нет заданий -

- ) : ( -
- {userRole === 'mentor' && fillLater.length > 0 && ( - 'Заполнить позже'} /> - )} - {pending.length > 0 && ( - (hw.is_overdue ? 'Просрочено' : 'Домашнее задание')} /> - )} - {submitted.length > 0 && ( - 'На проверке'} /> - )} - {userRole === 'mentor' && aiDraft.length > 0 && ( - 'Черновик от ИИ'} /> - )} - {returned.length > 0 && ( - 'На доработке'} /> - )} - {reviewed.length > 0 && ( - 'Проверено'} /> - )} -
- )} - - { - setDetailsOpen(false); - setSelectedHomework(null); - }} - onSuccess={loadHomework} - /> - - { - setSubmitOpen(false); - setSubmitId(null); - }} - onSuccess={loadHomework} - /> -
- ); -} +'use client'; + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useAuth } from '@/contexts/AuthContext'; +import { useSelectedChild } from '@/contexts/SelectedChildContext'; +import { + getHomework, + getHomeworkById, + type Homework, +} from '@/api/homework'; +import { HomeworkDetailsModal } from './HomeworkDetailsModal'; +import { SubmitHomeworkModal } from './SubmitHomeworkModal'; +import { DashboardLayout } from '@/components/dashboard/ui'; +import { LoadingSpinner } from '@/components/common/LoadingSpinner'; + +function formatDate(s: string | null): string { + if (!s) return '—'; + const d = new Date(s); + return isNaN(d.getTime()) ? '—' : d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +function formatTime(s: string | null): string { + if (!s) return '—'; + const d = new Date(s); + return isNaN(d.getTime()) ? '—' : d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); +} + +function getHomeworkStatus(hw: Homework): 'pending' | 'submitted' | 'returned' | 'reviewed' { + if (hw.status !== 'published') return 'pending'; + if (hw.checked_submissions > 0 && hw.checked_submissions === hw.total_submissions) return 'reviewed'; + if (hw.returned_submissions > 0 && hw.returned_submissions === hw.total_submissions) return 'returned'; + if (hw.total_submissions > 0) return 'submitted'; + return 'pending'; +} + +export function HomeworkPageContent() { + const { user } = useAuth(); + const { selectedChild } = useSelectedChild(); + const [homework, setHomework] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedHomework, setSelectedHomework] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + const [detailsLoading, setDetailsLoading] = useState(false); + const [submitId, setSubmitId] = useState(null); + const [submitOpen, setSubmitOpen] = useState(false); + + const loadHomework = useCallback(async () => { + try { + setLoading(true); + const res = await getHomework({ + page_size: 1000, + ...(user?.role === 'parent' && selectedChild?.id && { child_id: selectedChild.id }), + }); + setHomework(res.results); + } catch (e) { + setError(e instanceof Error ? e.message : 'Ошибка загрузки'); + } finally { + setLoading(false); + } + }, [user?.role, selectedChild?.id]); + + useEffect(() => { + loadHomework(); + }, [loadHomework]); + + const userRole = user?.role ?? ''; + + const pending = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'pending' && hw.status === 'published'), [homework]); + const submitted = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'submitted'), [homework]); + const returned = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'returned'), [homework]); + const reviewed = useMemo(() => homework.filter((hw) => getHomeworkStatus(hw) === 'reviewed'), [homework]); + /** Только для ментора: черновики «заполнить позже» — ожидают заполнения задания. */ + const fillLater = useMemo( + () => (userRole === 'mentor' ? homework.filter((hw) => hw.fill_later === true) : []), + [homework, userRole] + ); + /** Только для ментора: задания, у которых есть хотя бы одно решение с черновиком от ИИ. */ + const aiDraft = useMemo( + () => (userRole === 'mentor' ? homework.filter((hw) => (hw.ai_draft_count ?? 0) > 0) : []), + [homework, userRole] + ); + + const handleViewDetails = useCallback(async (hw: Homework) => { + try { + setDetailsLoading(true); + const full = await getHomeworkById(hw.id); + setSelectedHomework(full); + setDetailsOpen(true); + } catch { + setSelectedHomework(hw); + setDetailsOpen(true); + } finally { + setDetailsLoading(false); + } + }, []); + + const handleSubmit = useCallback((hw: Homework) => { + setSubmitId(hw.id); + setSubmitOpen(true); + }, []); + + const HomeworkCard = ({ + hw, + badge, + onView, + onSubmit, + }: { + hw: Homework; + badge: string; + onView: () => void; + onSubmit?: () => void; + }) => ( +
+
+ + {badge} + +
+

+ {hw.title} +

+
+ {userRole === 'client' && ( +
+ person + {hw.mentor.first_name} {hw.mentor.last_name} +
+ )} + {userRole === 'mentor' && hw.total_submissions > 0 && ( +
Решений: {hw.total_submissions}
+ )} + {hw.deadline && ( + <> +
+ calendar_today + {formatDate(hw.deadline)} {formatTime(hw.deadline)} +
+ + )} + {hw.student_score?.score != null && ( +
+ Оценка: {hw.student_score.score} / 5 +
+ )} +
+ {userRole === 'client' && onSubmit && getHomeworkStatus(hw) === 'pending' && ( + + )} +
+ ); + + const Column = ({ + title, + count, + items, + getBadge, + }: { + title: string; + count: number; + items: Homework[]; + getBadge: (hw: Homework) => string; + }) => ( +
+

+ {title} {count > 0 ? `(${count})` : ''} +

+
+ {items.map((hw) => ( + handleViewDetails(hw)} + onSubmit={userRole === 'client' ? () => handleSubmit(hw) : undefined} + /> + ))} + {items.length === 0 && ( +

+ Нет заданий +

+ )} +
+
+ ); + + return ( + + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
+ +
+ ) : homework.length === 0 ? ( +

+ Нет заданий +

+ ) : ( +
+ {userRole === 'mentor' && fillLater.length > 0 && ( + 'Заполнить позже'} /> + )} + {pending.length > 0 && ( + (hw.is_overdue ? 'Просрочено' : 'Домашнее задание')} /> + )} + {submitted.length > 0 && ( + 'На проверке'} /> + )} + {userRole === 'mentor' && aiDraft.length > 0 && ( + 'Черновик от ИИ'} /> + )} + {returned.length > 0 && ( + 'На доработке'} /> + )} + {reviewed.length > 0 && ( + 'Проверено'} /> + )} +
+ )} + + { + setDetailsOpen(false); + setSelectedHomework(null); + }} + onSuccess={loadHomework} + /> + + { + setSubmitOpen(false); + setSubmitId(null); + }} + onSuccess={loadHomework} + /> +
+ ); +} diff --git a/front_material/components/navigation/ChildSelector.tsx b/front_material/components/navigation/ChildSelector.tsx index 9f22628..8f6e8d9 100644 --- a/front_material/components/navigation/ChildSelector.tsx +++ b/front_material/components/navigation/ChildSelector.tsx @@ -1,319 +1,319 @@ -'use client'; - -import { useState, useRef, useEffect } from 'react'; -import { useSelectedChild } from '@/contexts/SelectedChildContext'; -import type { ChildStats } from '@/api/dashboard'; - -function getAvatarUrl(child: ChildStats | null): string | null { - if (!child) return null; - const url = child.avatar_url || child.avatar; - if (!url) return null; - if (typeof url === 'string' && url.startsWith('http')) return url; - const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : ''; - return typeof url === 'string' && url.startsWith('/') ? `${base}${url}` : `${base}/${url}`; -} - -/** Минималистичный выбор ребёнка слева от нижней навигации */ -export function ChildSelectorCompact() { - const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild(); - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const onOutside = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', onOutside); - return () => document.removeEventListener('mousedown', onOutside); - }, []); - - if (loading && childrenList.length === 0) { - return ( -
- person -
- ); - } - - if (childrenList.length === 0) return null; - - const avatarUrl = getAvatarUrl(selectedChild); - const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?'; - - return ( -
- - - {open && ( -
- {childrenList.map((child) => { - const isSelected = selectedChild?.id === child.id; - const url = getAvatarUrl(child); - return ( - - ); - })} -
- )} -
- ); -} - -export function ChildSelector() { - const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild(); - const [open, setOpen] = useState(false); - const ref = useRef(null); - - useEffect(() => { - const onOutside = (e: MouseEvent) => { - if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener('mousedown', onOutside); - return () => document.removeEventListener('mousedown', onOutside); - }, []); - - if (loading && childrenList.length === 0) { - return ( -
- Загрузка... -
- ); - } - - if (childrenList.length === 0) return null; - - const avatarUrl = getAvatarUrl(selectedChild); - const displayName = selectedChild?.name ?? 'Выберите ребёнка'; - - return ( -
- - - {open && ( -
- {childrenList.map((child) => { - const isSelected = selectedChild?.id === child.id; - const url = getAvatarUrl(child); - return ( - - ); - })} -
- )} -
- ); -} +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import { useSelectedChild } from '@/contexts/SelectedChildContext'; +import type { ChildStats } from '@/api/dashboard'; + +function getAvatarUrl(child: ChildStats | null): string | null { + if (!child) return null; + const url = child.avatar_url || child.avatar; + if (!url) return null; + if (typeof url === 'string' && url.startsWith('http')) return url; + const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : ''; + return typeof url === 'string' && url.startsWith('/') ? `${base}${url}` : `${base}/${url}`; +} + +/** Минималистичный выбор ребёнка слева от нижней навигации */ +export function ChildSelectorCompact() { + const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const onOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onOutside); + return () => document.removeEventListener('mousedown', onOutside); + }, []); + + if (loading && childrenList.length === 0) { + return ( +
+ person +
+ ); + } + + if (childrenList.length === 0) return null; + + const avatarUrl = getAvatarUrl(selectedChild); + const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?'; + + return ( +
+ + + {open && ( +
+ {childrenList.map((child) => { + const isSelected = selectedChild?.id === child.id; + const url = getAvatarUrl(child); + return ( + + ); + })} +
+ )} +
+ ); +} + +export function ChildSelector() { + const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild(); + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const onOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener('mousedown', onOutside); + return () => document.removeEventListener('mousedown', onOutside); + }, []); + + if (loading && childrenList.length === 0) { + return ( +
+ Загрузка... +
+ ); + } + + if (childrenList.length === 0) return null; + + const avatarUrl = getAvatarUrl(selectedChild); + const displayName = selectedChild?.name ?? 'Выберите ребёнка'; + + return ( +
+ + + {open && ( +
+ {childrenList.map((child) => { + const isSelected = selectedChild?.id === child.id; + const url = getAvatarUrl(child); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/front_material/components/profile/OnboardingTipsSection.tsx b/front_material/components/profile/OnboardingTipsSection.tsx index 9daeb34..9fb8877 100644 --- a/front_material/components/profile/OnboardingTipsSection.tsx +++ b/front_material/components/profile/OnboardingTipsSection.tsx @@ -1,149 +1,149 @@ -'use client'; - -import { useState, useEffect } from 'react'; -import { usePathname, useRouter } from 'next/navigation'; -import { useOnboarding } from '@/contexts/OnboardingContext'; -import { useAuth } from '@/contexts/AuthContext'; -import { getOnboardingKey } from '@/lib/onboarding-steps'; - -const PAGE_LABELS: Record = { - dashboard: 'Главная', - schedule: 'Расписание', - students: 'Студенты', - materials: 'Материалы', - homework: 'Домашние задания', - feedback: 'Обратная связь', - analytics: 'Аналитика', - payment: 'Тарифы', - referrals: 'Рефералы', - profile: 'Профиль', - chat: 'Чат', - 'my-progress': 'Прогресс', - 'request-mentor': 'Мои менторы', -}; - -const MENTOR_PAGES = ['dashboard', 'schedule', 'students', 'materials', 'homework', 'feedback', 'analytics', 'payment', 'referrals', 'profile']; -const CLIENT_PAGES = ['dashboard', 'schedule', 'chat', 'materials', 'homework', 'my-progress', 'request-mentor', 'profile']; - -export function OnboardingTipsSection() { - const onboarding = useOnboarding(); - const { user } = useAuth(); - const pathname = usePathname(); - const router = useRouter(); - const [progress, setProgress] = useState({ seen: 0, total: 0 }); - const [expanded, setExpanded] = useState(false); - - const role = user?.role === 'mentor' ? 'mentor' : user?.role === 'client' ? 'client' : user?.role === 'parent' ? 'parent' : null; - const pages = role === 'mentor' ? MENTOR_PAGES : role === 'client' ? CLIENT_PAGES : MENTOR_PAGES; - - useEffect(() => { - if (!onboarding) return; - onboarding.refreshProgress().then(() => { - setProgress(onboarding.getProgress()); - }); - }, [onboarding, pathname]); - - if (!onboarding || !role) return null; - - const currentKey = getOnboardingKey(pathname || '', role as 'mentor' | 'client' | 'parent'); - - const handleShowAgain = () => { - if (currentKey) onboarding.runTourManually(currentKey, { force: true }); - }; - - const handleShowOnPage = (pageKey: string) => { - setExpanded(false); - const path = pageKey === 'dashboard' ? '/dashboard' : `/${pageKey}`; - router.push(path); - setTimeout(() => onboarding.runTourManually(pageKey, { force: true }), 800); - }; - - if (progress.total === 0) return null; - - return ( -
-

- Подсказки по платформе -

-

- Пройдено {progress.seen} из {progress.total} страниц -

-
- {currentKey && ( - - )} - -
- {expanded && ( -
- {pages.map((key) => ( - - ))} -
- )} -
- ); -} +'use client'; + +import { useState, useEffect } from 'react'; +import { usePathname, useRouter } from 'next/navigation'; +import { useOnboarding } from '@/contexts/OnboardingContext'; +import { useAuth } from '@/contexts/AuthContext'; +import { getOnboardingKey } from '@/lib/onboarding-steps'; + +const PAGE_LABELS: Record = { + dashboard: 'Главная', + schedule: 'Расписание', + students: 'Студенты', + materials: 'Материалы', + homework: 'Домашние задания', + feedback: 'Обратная связь', + analytics: 'Аналитика', + payment: 'Тарифы', + referrals: 'Рефералы', + profile: 'Профиль', + chat: 'Чат', + 'my-progress': 'Прогресс', + 'request-mentor': 'Мои менторы', +}; + +const MENTOR_PAGES = ['dashboard', 'schedule', 'students', 'materials', 'homework', 'feedback', 'analytics', 'payment', 'referrals', 'profile']; +const CLIENT_PAGES = ['dashboard', 'schedule', 'chat', 'materials', 'homework', 'my-progress', 'request-mentor', 'profile']; + +export function OnboardingTipsSection() { + const onboarding = useOnboarding(); + const { user } = useAuth(); + const pathname = usePathname(); + const router = useRouter(); + const [progress, setProgress] = useState({ seen: 0, total: 0 }); + const [expanded, setExpanded] = useState(false); + + const role = user?.role === 'mentor' ? 'mentor' : user?.role === 'client' ? 'client' : user?.role === 'parent' ? 'parent' : null; + const pages = role === 'mentor' ? MENTOR_PAGES : role === 'client' ? CLIENT_PAGES : MENTOR_PAGES; + + useEffect(() => { + if (!onboarding) return; + onboarding.refreshProgress().then(() => { + setProgress(onboarding.getProgress()); + }); + }, [onboarding, pathname]); + + if (!onboarding || !role) return null; + + const currentKey = getOnboardingKey(pathname || '', role as 'mentor' | 'client' | 'parent'); + + const handleShowAgain = () => { + if (currentKey) onboarding.runTourManually(currentKey, { force: true }); + }; + + const handleShowOnPage = (pageKey: string) => { + setExpanded(false); + const path = pageKey === 'dashboard' ? '/dashboard' : `/${pageKey}`; + router.push(path); + setTimeout(() => onboarding.runTourManually(pageKey, { force: true }), 800); + }; + + if (progress.total === 0) return null; + + return ( +
+

+ Подсказки по платформе +

+

+ Пройдено {progress.seen} из {progress.total} страниц +

+
+ {currentKey && ( + + )} + +
+ {expanded && ( +
+ {pages.map((key) => ( + + ))} +
+ )} +
+ ); +} diff --git a/front_material/styles/driver-onboarding.css b/front_material/styles/driver-onboarding.css index 47cd593..530389e 100644 --- a/front_material/styles/driver-onboarding.css +++ b/front_material/styles/driver-onboarding.css @@ -1,85 +1,85 @@ -/** - * Симпатичная стилизация онбординг-туров (Driver.js). - * Мягкие тени, скругления, дружелюбная палитра. - */ - -.driver-popover.driver-onboarding-friendly { - background: var(--md-sys-color-surface-container-high, #fff); - border-radius: 20px; - box-shadow: 0 12px 40px rgba(103, 80, 164, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08); - border: 1px solid rgba(103, 80, 164, 0.12); - padding: 20px 24px; - max-width: 360px; -} - -.driver-popover.driver-onboarding-friendly .driver-popover-title { - font-size: 18px; - font-weight: 600; - color: var(--md-sys-color-on-surface, #1c1b1f); - margin-bottom: 10px; - line-height: 1.35; - letter-spacing: -0.01em; -} - -.driver-popover.driver-onboarding-friendly .driver-popover-description { - font-size: 15px; - line-height: 1.5; - color: var(--md-sys-color-on-surface-variant, #49454f); - margin-bottom: 20px; -} - -.driver-popover.driver-onboarding-friendly .driver-popover-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding-top: 4px; -} - -.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn, -.driver-popover.driver-onboarding-friendly .driver-popover-next-btn { - padding: 10px 20px; - border-radius: 12px; - font-size: 15px; - font-weight: 600; - border: none; - cursor: pointer; - transition: transform 0.15s ease, box-shadow 0.15s ease; -} - -.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn:hover, -.driver-popover.driver-onboarding-friendly .driver-popover-next-btn:hover { - transform: translateY(-1px); -} - -.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn { - background: var(--md-sys-color-surface-variant, #e7e0ec); - color: var(--md-sys-color-on-surface-variant, #49454f); -} - -.driver-popover.driver-onboarding-friendly .driver-popover-next-btn { - background: var(--md-sys-color-primary, #6750a4); - color: var(--md-sys-color-on-primary, #fff); -} - -.driver-popover.driver-onboarding-friendly .driver-popover-progress-text { - font-size: 13px; - color: var(--md-sys-color-outline, #79747e); - font-weight: 500; -} - -.driver-popover.driver-onboarding-friendly .driver-popover-arrow { - border-color: var(--md-sys-color-surface-container-high, #fff); -} - -.driver-overlay { - background: rgba(0, 0, 0, 0) !important; -} - -.driver-active-element { - border-radius: 16px !important; - box-shadow: - 0 0 0 4px rgba(103, 80, 164, 0.9), - 0 0 0 8px rgba(103, 80, 164, 0.25), - 0 0 32px rgba(103, 80, 164, 0.35) !important; -} +/** + * Симпатичная стилизация онбординг-туров (Driver.js). + * Мягкие тени, скругления, дружелюбная палитра. + */ + +.driver-popover.driver-onboarding-friendly { + background: var(--md-sys-color-surface-container-high, #fff); + border-radius: 20px; + box-shadow: 0 12px 40px rgba(103, 80, 164, 0.15), 0 4px 12px rgba(0, 0, 0, 0.08); + border: 1px solid rgba(103, 80, 164, 0.12); + padding: 20px 24px; + max-width: 360px; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-title { + font-size: 18px; + font-weight: 600; + color: var(--md-sys-color-on-surface, #1c1b1f); + margin-bottom: 10px; + line-height: 1.35; + letter-spacing: -0.01em; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-description { + font-size: 15px; + line-height: 1.5; + color: var(--md-sys-color-on-surface-variant, #49454f); + margin-bottom: 20px; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding-top: 4px; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn, +.driver-popover.driver-onboarding-friendly .driver-popover-next-btn { + padding: 10px 20px; + border-radius: 12px; + font-size: 15px; + font-weight: 600; + border: none; + cursor: pointer; + transition: transform 0.15s ease, box-shadow 0.15s ease; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn:hover, +.driver-popover.driver-onboarding-friendly .driver-popover-next-btn:hover { + transform: translateY(-1px); +} + +.driver-popover.driver-onboarding-friendly .driver-popover-prev-btn { + background: var(--md-sys-color-surface-variant, #e7e0ec); + color: var(--md-sys-color-on-surface-variant, #49454f); +} + +.driver-popover.driver-onboarding-friendly .driver-popover-next-btn { + background: var(--md-sys-color-primary, #6750a4); + color: var(--md-sys-color-on-primary, #fff); +} + +.driver-popover.driver-onboarding-friendly .driver-popover-progress-text { + font-size: 13px; + color: var(--md-sys-color-outline, #79747e); + font-weight: 500; +} + +.driver-popover.driver-onboarding-friendly .driver-popover-arrow { + border-color: var(--md-sys-color-surface-container-high, #fff); +} + +.driver-overlay { + background: rgba(0, 0, 0, 0) !important; +} + +.driver-active-element { + border-radius: 16px !important; + box-shadow: + 0 0 0 4px rgba(103, 80, 164, 0.9), + 0 0 0 8px rgba(103, 80, 164, 0.25), + 0 0 32px rgba(103, 80, 164, 0.35) !important; +} diff --git a/front_material/styles/globals.css b/front_material/styles/globals.css index 366a146..0a37670 100644 --- a/front_material/styles/globals.css +++ b/front_material/styles/globals.css @@ -342,6 +342,47 @@ img { overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; + /* Предотвращение layout shift при переходах */ + contain: layout style; +} + +/* Плавные переходы между страницами - предотвращение дёрганья */ +.protected-layout-root .protected-main > * { + animation: pageContentFadeIn 0.2s ease-out; + will-change: opacity; +} + +@keyframes pageContentFadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Стабилизация layout при загрузке - контент сразу занимает всё пространство */ +.ios26-dashboard, +.ios26-schedule-page, +.ios26-students-page, +.ios26-chat-page, +.ios26-materials-page, +.ios26-profile-page, +.ios26-homework-page, +.ios26-analytics-page, +.ios26-payment-page { + min-height: 100%; + width: 100%; + contain: layout style; +} + +/* Отключаем анимацию для уменьшения motion (accessibility) */ +@media (prefers-reduced-motion: reduce) { + .protected-layout-root .protected-main > * { + animation: none; + } } /* Ноутбук и выше (768px+): нижний бар fixed, bottom 20px, контенту отступ снизу */