fix
Deploy to Production / deploy-production (push) Successful in 26s
Details
Deploy to Production / deploy-production (push) Successful in 26s
Details
This commit is contained in:
parent
835bd76479
commit
47e134a857
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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} не найдена')
|
||||
|
||||
|
|
|
|||
|
|
@ -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}')
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -12,17 +12,7 @@ export default function DashboardPage() {
|
|||
const { selectedChild, loading: childLoading, childrenList } = useSelectedChild();
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '50vh',
|
||||
background: 'var(--md-sys-color-background)'
|
||||
}}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingSpinner size="large" fullPage />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
|
|
@ -38,17 +28,7 @@ export default function DashboardPage() {
|
|||
// Родитель: те же страницы, что и студент — показываем дашборд выбранного ребёнка
|
||||
if (user.role === 'parent') {
|
||||
if (childLoading && childrenList.length === 0) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
minHeight: '50vh',
|
||||
background: 'var(--md-sys-color-background)'
|
||||
}}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
return <LoadingSpinner size="large" fullPage />;
|
||||
}
|
||||
if (childrenList.length === 0) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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<Lesson[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user?.role !== 'mentor') {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
textAlign: 'center',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
}}
|
||||
>
|
||||
Страница доступна только менторам
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="ios26-panel"
|
||||
style={{
|
||||
padding: 16,
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
onClick={onFill}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--md-sys-color-primary-container)',
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{getSubjectName(lesson)}
|
||||
</span>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface)' }}>
|
||||
{lesson.title}
|
||||
</h4>
|
||||
<div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>person</span>
|
||||
{clientName}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>calendar_today</span>
|
||||
{formatDate(lesson.start_time)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>schedule</span>
|
||||
{formatTime(lesson.start_time)} — {formatTime(lesson.end_time)}
|
||||
</div>
|
||||
{lesson.mentor_grade != null && (
|
||||
<div style={{ marginTop: 8, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
|
||||
Оценка: {lesson.mentor_grade}/5
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background: lesson.mentor_notes || lesson.mentor_grade != null ? 'var(--md-sys-color-primary-container)' : 'var(--md-sys-color-primary)',
|
||||
color: lesson.mentor_notes || lesson.mentor_grade != null ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-primary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFill();
|
||||
}}
|
||||
>
|
||||
{lesson.mentor_notes || lesson.mentor_grade != null ? 'Редактировать' : 'Заполнить'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="feedback-root">
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
background: 'rgba(186,26,26,0.1)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--md-sys-color-error)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
|
||||
<LoadingSpinner size="medium" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="ios26-feedback-kanban">
|
||||
<div className="ios26-feedback-column">
|
||||
<h3 className="ios26-feedback-column__title">
|
||||
Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''}
|
||||
</h3>
|
||||
<div className="ios26-feedback-column__cards">
|
||||
{todoLessons.map((l) => (
|
||||
<LessonCard key={l.id} lesson={l} onFill={() => openFeedback(l)} />
|
||||
))}
|
||||
{todoLessons.length === 0 && (
|
||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}>
|
||||
Нет занятий
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ios26-feedback-column">
|
||||
<h3 className="ios26-feedback-column__title">
|
||||
Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''}
|
||||
</h3>
|
||||
<div className="ios26-feedback-column__cards">
|
||||
{doneLessons.map((l) => (
|
||||
<LessonCard key={l.id} lesson={l} onFill={() => openFeedback(l)} />
|
||||
))}
|
||||
{doneLessons.length === 0 && (
|
||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}>
|
||||
Нет занятий
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FeedbackModal
|
||||
isOpen={showModal}
|
||||
lesson={selectedLesson}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setSelectedLesson(null);
|
||||
}}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
'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<Lesson[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedLesson, setSelectedLesson] = useState<Lesson | null>(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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user?.role !== 'mentor') {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div
|
||||
style={{
|
||||
padding: 24,
|
||||
textAlign: 'center',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
}}
|
||||
>
|
||||
Страница доступна только менторам
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="ios26-panel"
|
||||
style={{
|
||||
padding: 16,
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.2s',
|
||||
}}
|
||||
onClick={onFill}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 8,
|
||||
background: 'var(--md-sys-color-primary-container)',
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{getSubjectName(lesson)}
|
||||
</span>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface)' }}>
|
||||
{lesson.title}
|
||||
</h4>
|
||||
<div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>person</span>
|
||||
{clientName}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>calendar_today</span>
|
||||
{formatDate(lesson.start_time)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>schedule</span>
|
||||
{formatTime(lesson.start_time)} — {formatTime(lesson.end_time)}
|
||||
</div>
|
||||
{lesson.mentor_grade != null && (
|
||||
<div style={{ marginTop: 8, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
|
||||
Оценка: {lesson.mentor_grade}/5
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background: lesson.mentor_notes || lesson.mentor_grade != null ? 'var(--md-sys-color-primary-container)' : 'var(--md-sys-color-primary)',
|
||||
color: lesson.mentor_notes || lesson.mentor_grade != null ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-on-primary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFill();
|
||||
}}
|
||||
>
|
||||
{lesson.mentor_notes || lesson.mentor_grade != null ? 'Редактировать' : 'Заполнить'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="feedback-root">
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
background: 'rgba(186,26,26,0.1)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--md-sys-color-error)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
|
||||
<LoadingSpinner size="medium" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="ios26-feedback-kanban">
|
||||
<div className="ios26-feedback-column">
|
||||
<h3 className="ios26-feedback-column__title">
|
||||
Ожидают {todoLessons.length > 0 ? `(${todoLessons.length})` : ''}
|
||||
</h3>
|
||||
<div className="ios26-feedback-column__cards">
|
||||
{todoLessons.map((l) => (
|
||||
<LessonCard key={l.id} lesson={l} onFill={() => openFeedback(l)} />
|
||||
))}
|
||||
{todoLessons.length === 0 && (
|
||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}>
|
||||
Нет занятий
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ios26-feedback-column">
|
||||
<h3 className="ios26-feedback-column__title">
|
||||
Заполнено {doneLessons.length > 0 ? `(${doneLessons.length})` : ''}
|
||||
</h3>
|
||||
<div className="ios26-feedback-column__cards">
|
||||
{doneLessons.map((l) => (
|
||||
<LessonCard key={l.id} lesson={l} onFill={() => openFeedback(l)} />
|
||||
))}
|
||||
{doneLessons.length === 0 && (
|
||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}>
|
||||
Нет занятий
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FeedbackModal
|
||||
isOpen={showModal}
|
||||
lesson={selectedLesson}
|
||||
onClose={() => {
|
||||
setShowModal(false);
|
||||
setSelectedLesson(null);
|
||||
}}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { HomeworkPageContent } from '@/components/homework/HomeworkPageContent';
|
||||
|
||||
export default function HomeworkPage() {
|
||||
return <HomeworkPageContent />;
|
||||
}
|
||||
'use client';
|
||||
|
||||
import { HomeworkPageContent } from '@/components/homework/HomeworkPageContent';
|
||||
|
||||
export default function HomeworkPage() {
|
||||
return <HomeworkPageContent />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
}}
|
||||
>
|
||||
// Стабильный loading layout - предотвращает дёрганье
|
||||
const loadingLayout = (
|
||||
<div className="protected-layout-root">
|
||||
<main className="protected-main" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<LoadingSpinner size="large" />
|
||||
</div>
|
||||
);
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return loadingLayout;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: 'var(--md-sys-color-background)' }}>
|
||||
<div style={{ textAlign: 'center', color: 'var(--md-sys-color-on-surface)' }}>
|
||||
<LoadingSpinner size="large" />
|
||||
<p style={{ marginTop: '16px', fontSize: '14px', opacity: 0.8 }}>Проверка авторизации...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return loadingLayout;
|
||||
}
|
||||
|
||||
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
|
||||
|
|
|
|||
|
|
@ -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: () => <LoadingSpinner size="medium" /> }
|
||||
);
|
||||
|
||||
export default function PaymentPage() {
|
||||
return (
|
||||
<div style={{ padding: 24 }} data-tour="payment-root">
|
||||
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24, color: 'var(--md-sys-color-on-surface)' }}>
|
||||
Подписки и оплата
|
||||
</h1>
|
||||
<ProfilePaymentTab />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'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: () => <LoadingSpinner size="medium" /> }
|
||||
);
|
||||
|
||||
export default function PaymentPage() {
|
||||
return (
|
||||
<div style={{ padding: 24 }} data-tour="payment-root">
|
||||
<h1 style={{ fontSize: 24, fontWeight: 600, marginBottom: 24, color: 'var(--md-sys-color-on-surface)' }}>
|
||||
Подписки и оплата
|
||||
</h1>
|
||||
<ProfilePaymentTab />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<Box
|
||||
className="ios-glass-panel"
|
||||
data-tour="chat-list"
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
p: 2,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
background: 'var(--ios26-glass)',
|
||||
border: '1px solid var(--ios26-glass-border)',
|
||||
backdropFilter: 'var(--ios26-blur)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Поиск"
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => 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)' }
|
||||
}}
|
||||
>
|
||||
<AddCommentRoundedIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
pr: 0.5,
|
||||
}}
|
||||
>
|
||||
<List disablePadding>
|
||||
{filtered.map((chat) => {
|
||||
const selected = !!selectedChatUuid && chat.uuid === selectedChatUuid;
|
||||
const preview = parsePreview(chat.last_message || '');
|
||||
return (
|
||||
<ListItemButton
|
||||
key={chat.uuid || chat.id}
|
||||
selected={selected}
|
||||
onClick={() => onSelect(chat)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
minWidth: 0,
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'rgba(116, 68, 253, 0.12)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', mr: 1.25, width: 36, height: 36, flex: '0 0 36px' }}>
|
||||
<Avatar
|
||||
src={(chat as any).avatar_url || undefined}
|
||||
alt={chat.participant_name || 'Аватар'}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: 'rgba(116, 68, 253, 0.18)',
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
fontWeight: 800,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{(chat.participant_name || 'Ч')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0])
|
||||
.join('')
|
||||
.toUpperCase()}
|
||||
</Avatar>
|
||||
{!!(chat as any).other_is_online && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: -1,
|
||||
bottom: -1,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '999px',
|
||||
backgroundColor: '#22c55e',
|
||||
border: '2px solid rgba(255,255,255,0.9)',
|
||||
boxShadow: '0 0 0 1px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}
|
||||
>
|
||||
{chat.participant_name || 'Чат'}
|
||||
</Box>
|
||||
{!!chat.unread_count && chat.unread_count > 0 && (
|
||||
<Badge
|
||||
color="primary"
|
||||
badgeContent={chat.unread_count}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
backgroundColor: 'var(--md-sys-color-primary)',
|
||||
color: 'var(--md-sys-color-on-primary)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
pr: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
{preview.icons.includes('bell') && (
|
||||
<NotificationsActiveRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-primary)' }} />
|
||||
)}
|
||||
{preview.icons.includes('trash') && (
|
||||
<DeleteRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }} />
|
||||
)}
|
||||
{preview.icons.includes('note') && (
|
||||
<AssignmentRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-primary)' }} />
|
||||
)}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{preview.text}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
sx={{ minWidth: 0 }}
|
||||
primaryTypographyProps={{ component: 'span' }}
|
||||
secondaryTypographyProps={{ component: 'span' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
{hasMore && onLoadMore && (
|
||||
<Box sx={{ py: 1.5, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
borderColor: 'var(--md-sys-color-outline-variant)',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
}}
|
||||
>
|
||||
{loadingMore ? 'Загрузка…' : 'Загрузить ещё'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<NewChatModal
|
||||
isOpen={isNewChatModalOpen}
|
||||
onClose={() => setIsNewChatModalOpen(false)}
|
||||
onChatCreated={(chat) => {
|
||||
onSelect(chat);
|
||||
// Можно добавить уведомление
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
'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 (
|
||||
<Box
|
||||
className="ios-glass-panel"
|
||||
data-tour="chat-list"
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
p: 2,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 1.5,
|
||||
background: 'var(--ios26-glass)',
|
||||
border: '1px solid var(--ios26-glass-border)',
|
||||
backdropFilter: 'var(--ios26-blur)',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<TextField
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="Поиск"
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
borderRadius: 3,
|
||||
backgroundColor: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => 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)' }
|
||||
}}
|
||||
>
|
||||
<AddCommentRoundedIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
pr: 0.5,
|
||||
}}
|
||||
>
|
||||
<List disablePadding>
|
||||
{filtered.map((chat) => {
|
||||
const selected = !!selectedChatUuid && chat.uuid === selectedChatUuid;
|
||||
const preview = parsePreview(chat.last_message || '');
|
||||
return (
|
||||
<ListItemButton
|
||||
key={chat.uuid || chat.id}
|
||||
selected={selected}
|
||||
onClick={() => onSelect(chat)}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
mb: 0.5,
|
||||
minWidth: 0,
|
||||
'&.Mui-selected': {
|
||||
backgroundColor: 'rgba(116, 68, 253, 0.12)',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', mr: 1.25, width: 36, height: 36, flex: '0 0 36px' }}>
|
||||
<Avatar
|
||||
src={(chat as any).avatar_url || undefined}
|
||||
alt={chat.participant_name || 'Аватар'}
|
||||
sx={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
bgcolor: 'rgba(116, 68, 253, 0.18)',
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
fontWeight: 800,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{(chat.participant_name || 'Ч')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.slice(0, 2)
|
||||
.map((p) => p[0])
|
||||
.join('')
|
||||
.toUpperCase()}
|
||||
</Avatar>
|
||||
{!!(chat as any).other_is_online && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: -1,
|
||||
bottom: -1,
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '999px',
|
||||
backgroundColor: '#22c55e',
|
||||
border: '2px solid rgba(255,255,255,0.9)',
|
||||
boxShadow: '0 0 0 1px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
<ListItemText
|
||||
primary={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{ fontWeight: 700, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}
|
||||
>
|
||||
{chat.participant_name || 'Чат'}
|
||||
</Box>
|
||||
{!!chat.unread_count && chat.unread_count > 0 && (
|
||||
<Badge
|
||||
color="primary"
|
||||
badgeContent={chat.unread_count}
|
||||
sx={{
|
||||
'& .MuiBadge-badge': {
|
||||
backgroundColor: 'var(--md-sys-color-primary)',
|
||||
color: 'var(--md-sys-color-on-primary)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
pr: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
}}
|
||||
>
|
||||
{preview.icons.includes('bell') && (
|
||||
<NotificationsActiveRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-primary)' }} />
|
||||
)}
|
||||
{preview.icons.includes('trash') && (
|
||||
<DeleteRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }} />
|
||||
)}
|
||||
{preview.icons.includes('note') && (
|
||||
<AssignmentRoundedIcon sx={{ fontSize: 16, color: 'var(--md-sys-color-primary)' }} />
|
||||
)}
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{preview.text}
|
||||
</Box>
|
||||
</Box>
|
||||
}
|
||||
sx={{ minWidth: 0 }}
|
||||
primaryTypographyProps={{ component: 'span' }}
|
||||
secondaryTypographyProps={{ component: 'span' }}
|
||||
/>
|
||||
</ListItemButton>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
{hasMore && onLoadMore && (
|
||||
<Box sx={{ py: 1.5, display: 'flex', justifyContent: 'center' }}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={onLoadMore}
|
||||
disabled={loadingMore}
|
||||
sx={{
|
||||
borderRadius: 2,
|
||||
textTransform: 'none',
|
||||
borderColor: 'var(--md-sys-color-outline-variant)',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
}}
|
||||
>
|
||||
{loadingMore ? 'Загрузка…' : 'Загрузить ещё'}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<NewChatModal
|
||||
isOpen={isNewChatModalOpen}
|
||||
onClose={() => setIsNewChatModalOpen(false)}
|
||||
onChatCreated={(chat) => {
|
||||
onSelect(chat);
|
||||
// Можно добавить уведомление
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</span>
|
||||
);
|
||||
}
|
||||
if (fullPage) {
|
||||
return (
|
||||
<div style={fullPageStyle}>
|
||||
<span
|
||||
className="material-symbols-outlined"
|
||||
style={{ fontSize: sizeMap[size], animation: 'lk-spin 1s linear infinite', color: 'var(--md-sys-color-primary)' }}
|
||||
>
|
||||
progress_activity
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div>Загрузка...</div>;
|
||||
}
|
||||
|
||||
|
|
@ -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' }),
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -41,10 +41,11 @@ export const LessonCard: React.FC<LessonCardProps> = ({
|
|||
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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCalendar = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRevenue = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconFile = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
||||
<polyline points="13 2 13 9 20 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClipboard = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconTrendingUp = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
|
||||
<polyline points="17 6 23 6 23 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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<ExtraStatsSectionProps> = ({ stats, loading }) => {
|
||||
const rows = buildRows(stats, loading).slice(0, 9);
|
||||
|
||||
return (
|
||||
<Panel padding="md" data-tour="mentor-extrastats">
|
||||
<SectionHeader title="Статистика" />
|
||||
<div className="ios26-stat-grid">
|
||||
{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 (
|
||||
<div key={index} className="ios26-stat-tile">
|
||||
{row.icon && <div className="ios26-stat-icon">{row.icon}</div>}
|
||||
<div className="ios26-stat-label">{row.label.replace(/:$/, '')}</div>
|
||||
<div className={`ios26-stat-value${highlightClass}`}>{row.value}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Секция «Статистика» (список) для дашборда ментора (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 = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="9" cy="7" r="4" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCheck = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconCalendar = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconRevenue = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<line x1="12" y1="1" x2="12" y2="23" />
|
||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconFile = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z" />
|
||||
<polyline points="13 2 13 9 20 9" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconClipboard = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const IconTrendingUp = (
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="23 6 13.5 15.5 8.5 10.5 1 18" />
|
||||
<polyline points="17 6 23 6 23 12" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
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<ExtraStatsSectionProps> = ({ stats, loading }) => {
|
||||
const rows = buildRows(stats, loading).slice(0, 9);
|
||||
|
||||
return (
|
||||
<Panel padding="md" data-tour="mentor-extrastats">
|
||||
<SectionHeader title="Статистика" />
|
||||
<div className="ios26-stat-grid">
|
||||
{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 (
|
||||
<div key={index} className="ios26-stat-tile">
|
||||
{row.icon && <div className="ios26-stat-icon">{row.icon}</div>}
|
||||
<div className="ios26-stat-label">{row.label.replace(/:$/, '')}</div>
|
||||
<div className={`ios26-stat-value${highlightClass}`}>{row.value}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<DashboardLayoutProps> = ({ children, className = '', 'data-tour': dataTour }) => {
|
||||
return (
|
||||
<div className={`ios26-dashboard ${className}`.trim()} data-tour={dataTour}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Общая обёртка страницы дашборда.
|
||||
* Переиспользуется для ментора, клиента, родителя и др. ролей.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface DashboardLayoutProps {
|
||||
children: React.ReactNode;
|
||||
/** Дополнительный класс для контейнера */
|
||||
className?: string;
|
||||
/** data-tour для онбординга */
|
||||
'data-tour'?: string;
|
||||
}
|
||||
|
||||
export const DashboardLayout: React.FC<DashboardLayoutProps> = ({ children, className = '', 'data-tour': dataTour }) => {
|
||||
return (
|
||||
<div className={`ios26-dashboard ${className}`.trim()} data-tour={dataTour}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<PanelProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
interactive = false,
|
||||
padding = 'md',
|
||||
style,
|
||||
'data-tour': dataTour,
|
||||
}) => {
|
||||
const p = paddingMap[padding];
|
||||
return (
|
||||
<div
|
||||
data-tour={dataTour}
|
||||
className={`ios26-panel ${interactive ? 'ios26-panel-interactive' : ''} ${className}`.trim()}
|
||||
style={{
|
||||
padding: p ? `${p}px` : 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
/**
|
||||
* Стеклянная панель в стиле 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<PanelProps> = ({
|
||||
children,
|
||||
className = '',
|
||||
interactive = false,
|
||||
padding = 'md',
|
||||
style,
|
||||
'data-tour': dataTour,
|
||||
}) => {
|
||||
const p = paddingMap[padding];
|
||||
return (
|
||||
<div
|
||||
data-tour={dataTour}
|
||||
className={`ios26-panel ${interactive ? 'ios26-panel-interactive' : ''} ${className}`.trim()}
|
||||
style={{
|
||||
padding: p ? `${p}px` : 0,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<Homework[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedHomework, setSelectedHomework] = useState<Homework | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [detailsLoading, setDetailsLoading] = useState(false);
|
||||
const [submitId, setSubmitId] = useState<number | null>(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;
|
||||
}) => (
|
||||
<div
|
||||
className="ios26-panel"
|
||||
style={{ padding: 16, cursor: 'pointer' }}
|
||||
onClick={onView}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 8,
|
||||
background: hw.is_overdue ? 'rgba(186,26,26,0.15)' : 'var(--md-sys-color-primary-container)',
|
||||
color: hw.is_overdue ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface)' }}>
|
||||
{hw.title}
|
||||
</h4>
|
||||
<div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||
{userRole === 'client' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>person</span>
|
||||
{hw.mentor.first_name} {hw.mentor.last_name}
|
||||
</div>
|
||||
)}
|
||||
{userRole === 'mentor' && hw.total_submissions > 0 && (
|
||||
<div style={{ marginBottom: 4 }}>Решений: {hw.total_submissions}</div>
|
||||
)}
|
||||
{hw.deadline && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>calendar_today</span>
|
||||
{formatDate(hw.deadline)} {formatTime(hw.deadline)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hw.student_score?.score != null && (
|
||||
<div style={{ marginTop: 8, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
|
||||
Оценка: {hw.student_score.score} / 5
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{userRole === 'client' && onSubmit && getHomeworkStatus(hw) === 'pending' && (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background: 'var(--md-sys-color-primary)',
|
||||
color: 'var(--md-sys-color-on-primary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
Сдать ДЗ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Column = ({
|
||||
title,
|
||||
count,
|
||||
items,
|
||||
getBadge,
|
||||
}: {
|
||||
title: string;
|
||||
count: number;
|
||||
items: Homework[];
|
||||
getBadge: (hw: Homework) => string;
|
||||
}) => (
|
||||
<div className="ios26-feedback-column">
|
||||
<h3 className="ios26-feedback-column__title">
|
||||
{title} {count > 0 ? `(${count})` : ''}
|
||||
</h3>
|
||||
<div className="ios26-feedback-column__cards">
|
||||
{items.map((hw) => (
|
||||
<HomeworkCard
|
||||
key={hw.id}
|
||||
hw={hw}
|
||||
badge={getBadge(hw)}
|
||||
onView={() => handleViewDetails(hw)}
|
||||
onSubmit={userRole === 'client' ? () => handleSubmit(hw) : undefined}
|
||||
/>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}>
|
||||
Нет заданий
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="homework-root">
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
background: 'rgba(186,26,26,0.1)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--md-sys-color-error)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
|
||||
<LoadingSpinner size="medium" />
|
||||
</div>
|
||||
) : homework.length === 0 ? (
|
||||
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)', padding: 24 }}>
|
||||
Нет заданий
|
||||
</p>
|
||||
) : (
|
||||
<div className="ios26-feedback-kanban ios26-homework-kanban">
|
||||
{userRole === 'mentor' && fillLater.length > 0 && (
|
||||
<Column title="Ожидают заполнения" count={fillLater.length} items={fillLater} getBadge={() => 'Заполнить позже'} />
|
||||
)}
|
||||
{pending.length > 0 && (
|
||||
<Column title="Ожидают" count={pending.length} items={pending} getBadge={(hw) => (hw.is_overdue ? 'Просрочено' : 'Домашнее задание')} />
|
||||
)}
|
||||
{submitted.length > 0 && (
|
||||
<Column title="На проверке" count={submitted.length} items={submitted} getBadge={() => 'На проверке'} />
|
||||
)}
|
||||
{userRole === 'mentor' && aiDraft.length > 0 && (
|
||||
<Column title="Черновик от ИИ" count={aiDraft.length} items={aiDraft} getBadge={() => 'Черновик от ИИ'} />
|
||||
)}
|
||||
{returned.length > 0 && (
|
||||
<Column title="На доработке" count={returned.length} items={returned} getBadge={() => 'На доработке'} />
|
||||
)}
|
||||
{reviewed.length > 0 && (
|
||||
<Column title="Проверено" count={reviewed.length} items={reviewed} getBadge={() => 'Проверено'} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HomeworkDetailsModal
|
||||
isOpen={detailsOpen}
|
||||
homework={selectedHomework}
|
||||
userRole={userRole}
|
||||
childId={userRole === 'parent' ? selectedChild?.id ?? null : null}
|
||||
onClose={() => {
|
||||
setDetailsOpen(false);
|
||||
setSelectedHomework(null);
|
||||
}}
|
||||
onSuccess={loadHomework}
|
||||
/>
|
||||
|
||||
<SubmitHomeworkModal
|
||||
isOpen={submitOpen}
|
||||
homeworkId={submitId ?? 0}
|
||||
onClose={() => {
|
||||
setSubmitOpen(false);
|
||||
setSubmitId(null);
|
||||
}}
|
||||
onSuccess={loadHomework}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
'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<Homework[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedHomework, setSelectedHomework] = useState<Homework | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [detailsLoading, setDetailsLoading] = useState(false);
|
||||
const [submitId, setSubmitId] = useState<number | null>(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;
|
||||
}) => (
|
||||
<div
|
||||
className="ios26-panel"
|
||||
style={{ padding: 16, cursor: 'pointer' }}
|
||||
onClick={onView}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 8,
|
||||
background: hw.is_overdue ? 'rgba(186,26,26,0.15)' : 'var(--md-sys-color-primary-container)',
|
||||
color: hw.is_overdue ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-primary)',
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
</div>
|
||||
<h4 style={{ fontSize: 15, fontWeight: 600, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface)' }}>
|
||||
{hw.title}
|
||||
</h4>
|
||||
<div style={{ fontSize: 13, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||
{userRole === 'client' && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>person</span>
|
||||
{hw.mentor.first_name} {hw.mentor.last_name}
|
||||
</div>
|
||||
)}
|
||||
{userRole === 'mentor' && hw.total_submissions > 0 && (
|
||||
<div style={{ marginBottom: 4 }}>Решений: {hw.total_submissions}</div>
|
||||
)}
|
||||
{hw.deadline && (
|
||||
<>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>calendar_today</span>
|
||||
{formatDate(hw.deadline)} {formatTime(hw.deadline)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hw.student_score?.score != null && (
|
||||
<div style={{ marginTop: 8, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
|
||||
Оценка: {hw.student_score.score} / 5
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{userRole === 'client' && onSubmit && getHomeworkStatus(hw) === 'pending' && (
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
marginTop: 12,
|
||||
width: '100%',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 12,
|
||||
border: 'none',
|
||||
background: 'var(--md-sys-color-primary)',
|
||||
color: 'var(--md-sys-color-on-primary)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSubmit();
|
||||
}}
|
||||
>
|
||||
Сдать ДЗ
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const Column = ({
|
||||
title,
|
||||
count,
|
||||
items,
|
||||
getBadge,
|
||||
}: {
|
||||
title: string;
|
||||
count: number;
|
||||
items: Homework[];
|
||||
getBadge: (hw: Homework) => string;
|
||||
}) => (
|
||||
<div className="ios26-feedback-column">
|
||||
<h3 className="ios26-feedback-column__title">
|
||||
{title} {count > 0 ? `(${count})` : ''}
|
||||
</h3>
|
||||
<div className="ios26-feedback-column__cards">
|
||||
{items.map((hw) => (
|
||||
<HomeworkCard
|
||||
key={hw.id}
|
||||
hw={hw}
|
||||
badge={getBadge(hw)}
|
||||
onView={() => handleViewDetails(hw)}
|
||||
onSubmit={userRole === 'client' ? () => handleSubmit(hw) : undefined}
|
||||
/>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', padding: 16 }}>
|
||||
Нет заданий
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardLayout className="ios26-dashboard ios26-feedback-page" data-tour="homework-root">
|
||||
{error && (
|
||||
<div
|
||||
style={{
|
||||
padding: 16,
|
||||
marginBottom: 16,
|
||||
background: 'rgba(186,26,26,0.1)',
|
||||
borderRadius: 12,
|
||||
color: 'var(--md-sys-color-error)',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: 48 }}>
|
||||
<LoadingSpinner size="medium" />
|
||||
</div>
|
||||
) : homework.length === 0 ? (
|
||||
<p style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)', padding: 24 }}>
|
||||
Нет заданий
|
||||
</p>
|
||||
) : (
|
||||
<div className="ios26-feedback-kanban ios26-homework-kanban">
|
||||
{userRole === 'mentor' && fillLater.length > 0 && (
|
||||
<Column title="Ожидают заполнения" count={fillLater.length} items={fillLater} getBadge={() => 'Заполнить позже'} />
|
||||
)}
|
||||
{pending.length > 0 && (
|
||||
<Column title="Ожидают" count={pending.length} items={pending} getBadge={(hw) => (hw.is_overdue ? 'Просрочено' : 'Домашнее задание')} />
|
||||
)}
|
||||
{submitted.length > 0 && (
|
||||
<Column title="На проверке" count={submitted.length} items={submitted} getBadge={() => 'На проверке'} />
|
||||
)}
|
||||
{userRole === 'mentor' && aiDraft.length > 0 && (
|
||||
<Column title="Черновик от ИИ" count={aiDraft.length} items={aiDraft} getBadge={() => 'Черновик от ИИ'} />
|
||||
)}
|
||||
{returned.length > 0 && (
|
||||
<Column title="На доработке" count={returned.length} items={returned} getBadge={() => 'На доработке'} />
|
||||
)}
|
||||
{reviewed.length > 0 && (
|
||||
<Column title="Проверено" count={reviewed.length} items={reviewed} getBadge={() => 'Проверено'} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HomeworkDetailsModal
|
||||
isOpen={detailsOpen}
|
||||
homework={selectedHomework}
|
||||
userRole={userRole}
|
||||
childId={userRole === 'parent' ? selectedChild?.id ?? null : null}
|
||||
onClose={() => {
|
||||
setDetailsOpen(false);
|
||||
setSelectedHomework(null);
|
||||
}}
|
||||
onSuccess={loadHomework}
|
||||
/>
|
||||
|
||||
<SubmitHomeworkModal
|
||||
isOpen={submitOpen}
|
||||
homeworkId={submitId ?? 0}
|
||||
onClose={() => {
|
||||
setSubmitOpen(false);
|
||||
setSubmitId(null);
|
||||
}}
|
||||
onSuccess={loadHomework}
|
||||
/>
|
||||
</DashboardLayout>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className="ios26-bottom-nav-button"
|
||||
style={{ width: 44, minWidth: 44, flexShrink: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="ios26-bottom-nav-icon" style={{ opacity: 0.5 }}>person</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (childrenList.length === 0) return null;
|
||||
|
||||
const avatarUrl = getAvatarUrl(selectedChild);
|
||||
const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?';
|
||||
|
||||
return (
|
||||
<div ref={ref} data-tour="parent-child-selector" style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="ios26-bottom-nav-button"
|
||||
style={{
|
||||
width: 44,
|
||||
minWidth: 44,
|
||||
padding: 6,
|
||||
}}
|
||||
aria-label={selectedChild ? `Студент: ${selectedChild.name}` : 'Выбрать студента'}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--md-sys-color-primary-container)',
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
</span>
|
||||
<span className="ios26-bottom-nav-label" style={{ fontSize: 9 }}>Студент</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
marginBottom: 6,
|
||||
minWidth: 160,
|
||||
maxWidth: 220,
|
||||
background: 'var(--md-sys-color-surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
zIndex: 10052,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{childrenList.map((child) => {
|
||||
const isSelected = selectedChild?.id === child.id;
|
||||
const url = getAvatarUrl(child);
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedChild(child);
|
||||
setOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: 'none',
|
||||
background: isSelected ? 'var(--md-sys-color-primary-container)' : 'transparent',
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--md-sys-color-surface-variant)',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{url ? (
|
||||
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
child.name.charAt(0).toUpperCase() || '?'
|
||||
)}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{child.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChildSelector() {
|
||||
const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
background: 'var(--md-sys-color-surface-container)',
|
||||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||
fontSize: 14,
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
}}>
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (childrenList.length === 0) return null;
|
||||
|
||||
const avatarUrl = getAvatarUrl(selectedChild);
|
||||
const displayName = selectedChild?.name ?? 'Выберите ребёнка';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
background: 'var(--md-sys-color-surface-container)',
|
||||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||
position: 'relative',
|
||||
zIndex: 10050,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||
background: 'var(--md-sys-color-surface)',
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--md-sys-color-primary-container)',
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase() || '?'
|
||||
)}
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>{displayName}</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20, opacity: 0.7 }}>
|
||||
{open ? 'expand_less' : 'expand_more'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 16,
|
||||
right: 16,
|
||||
marginTop: 4,
|
||||
background: 'var(--md-sys-color-surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
zIndex: 10051,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{childrenList.map((child) => {
|
||||
const isSelected = selectedChild?.id === child.id;
|
||||
const url = getAvatarUrl(child);
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedChild(child);
|
||||
setOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: isSelected ? 'var(--md-sys-color-primary-container)' : 'transparent',
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
fontSize: 15,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--md-sys-color-surface-variant)',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{url ? (
|
||||
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
child.name.charAt(0).toUpperCase() || '?'
|
||||
)}
|
||||
</span>
|
||||
{child.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'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<HTMLDivElement>(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 (
|
||||
<div
|
||||
className="ios26-bottom-nav-button"
|
||||
style={{ width: 44, minWidth: 44, flexShrink: 0 }}
|
||||
aria-hidden
|
||||
>
|
||||
<span className="ios26-bottom-nav-icon" style={{ opacity: 0.5 }}>person</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (childrenList.length === 0) return null;
|
||||
|
||||
const avatarUrl = getAvatarUrl(selectedChild);
|
||||
const initial = selectedChild?.name?.charAt(0)?.toUpperCase() ?? '?';
|
||||
|
||||
return (
|
||||
<div ref={ref} data-tour="parent-child-selector" style={{ position: 'relative', flexShrink: 0 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="ios26-bottom-nav-button"
|
||||
style={{
|
||||
width: 44,
|
||||
minWidth: 44,
|
||||
padding: 6,
|
||||
}}
|
||||
aria-label={selectedChild ? `Студент: ${selectedChild.name}` : 'Выбрать студента'}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--md-sys-color-primary-container)',
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
initial
|
||||
)}
|
||||
</span>
|
||||
<span className="ios26-bottom-nav-label" style={{ fontSize: 9 }}>Студент</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: 0,
|
||||
marginBottom: 6,
|
||||
minWidth: 160,
|
||||
maxWidth: 220,
|
||||
background: 'var(--md-sys-color-surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.15)',
|
||||
zIndex: 10052,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{childrenList.map((child) => {
|
||||
const isSelected = selectedChild?.id === child.id;
|
||||
const url = getAvatarUrl(child);
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedChild(child);
|
||||
setOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: 'none',
|
||||
background: isSelected ? 'var(--md-sys-color-primary-container)' : 'transparent',
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
fontSize: 14,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--md-sys-color-surface-variant)',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{url ? (
|
||||
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
child.name.charAt(0).toUpperCase() || '?'
|
||||
)}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{child.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChildSelector() {
|
||||
const { selectedChild, childrenList, setSelectedChild, loading } = useSelectedChild();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(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 (
|
||||
<div style={{
|
||||
padding: '10px 16px',
|
||||
background: 'var(--md-sys-color-surface-container)',
|
||||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||
fontSize: 14,
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
}}>
|
||||
Загрузка...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (childrenList.length === 0) return null;
|
||||
|
||||
const avatarUrl = getAvatarUrl(selectedChild);
|
||||
const displayName = selectedChild?.name ?? 'Выберите ребёнка';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
background: 'var(--md-sys-color-surface-container)',
|
||||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||
position: 'relative',
|
||||
zIndex: 10050,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||
background: 'var(--md-sys-color-surface)',
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
fontSize: 15,
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--md-sys-color-primary-container)',
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
fontSize: 16,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<img src={avatarUrl} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
displayName.charAt(0).toUpperCase() || '?'
|
||||
)}
|
||||
</span>
|
||||
<span style={{ flex: 1 }}>{displayName}</span>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20, opacity: 0.7 }}>
|
||||
{open ? 'expand_less' : 'expand_more'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: 16,
|
||||
right: 16,
|
||||
marginTop: 4,
|
||||
background: 'var(--md-sys-color-surface)',
|
||||
borderRadius: 12,
|
||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
zIndex: 10051,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{childrenList.map((child) => {
|
||||
const isSelected = selectedChild?.id === child.id;
|
||||
const url = getAvatarUrl(child);
|
||||
return (
|
||||
<button
|
||||
key={child.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectedChild(child);
|
||||
setOpen(false);
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: 'none',
|
||||
background: isSelected ? 'var(--md-sys-color-primary-container)' : 'transparent',
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
fontSize: 15,
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'var(--md-sys-color-surface-variant)',
|
||||
color: 'var(--md-sys-color-on-surface-variant)',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{url ? (
|
||||
<img src={url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||
) : (
|
||||
child.name.charAt(0).toUpperCase() || '?'
|
||||
)}
|
||||
</span>
|
||||
{child.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
className="ios26-panel"
|
||||
style={{
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
background: 'var(--md-sys-color-surface-container-low)',
|
||||
border: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 12px 0', color: 'var(--md-sys-color-on-surface)' }}>
|
||||
Подсказки по платформе
|
||||
</h3>
|
||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: '0 0 16px 0', lineHeight: 1.5 }}>
|
||||
Пройдено {progress.seen} из {progress.total} страниц
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{currentKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowAgain}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--md-sys-color-on-primary)',
|
||||
background: 'var(--md-sys-color-primary)',
|
||||
border: 'none',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Показать подсказки снова
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--md-sys-color-primary)',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Свернуть' : 'Подсказки на другой странице'}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{pages.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => handleShowOnPage(key)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
background: 'var(--md-sys-color-surface-container-high)',
|
||||
border: 'none',
|
||||
borderRadius: 10,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{PAGE_LABELS[key] || key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
'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<string, string> = {
|
||||
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 (
|
||||
<div
|
||||
className="ios26-panel"
|
||||
style={{
|
||||
padding: 20,
|
||||
borderRadius: 16,
|
||||
background: 'var(--md-sys-color-surface-container-low)',
|
||||
border: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 12px 0', color: 'var(--md-sys-color-on-surface)' }}>
|
||||
Подсказки по платформе
|
||||
</h3>
|
||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)', margin: '0 0 16px 0', lineHeight: 1.5 }}>
|
||||
Пройдено {progress.seen} из {progress.total} страниц
|
||||
</p>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
{currentKey && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShowAgain}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--md-sys-color-on-primary)',
|
||||
background: 'var(--md-sys-color-primary)',
|
||||
border: 'none',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Показать подсказки снова
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
fontSize: 14,
|
||||
fontWeight: 500,
|
||||
color: 'var(--md-sys-color-primary)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--md-sys-color-primary)',
|
||||
borderRadius: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{expanded ? 'Свернуть' : 'Подсказки на другой странице'}
|
||||
</button>
|
||||
</div>
|
||||
{expanded && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 16,
|
||||
paddingTop: 16,
|
||||
borderTop: '1px solid var(--md-sys-color-outline-variant, rgba(0,0,0,0.08))',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{pages.map((key) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => handleShowOnPage(key)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
color: 'var(--md-sys-color-on-surface)',
|
||||
background: 'var(--md-sys-color-surface-container-high)',
|
||||
border: 'none',
|
||||
borderRadius: 10,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{PAGE_LABELS[key] || key}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, контенту отступ снизу */
|
||||
|
|
|
|||
Loading…
Reference in New Issue