mobile
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
b4b99491ae
commit
d4c4dbb087
|
|
@ -99,9 +99,19 @@ class UpdateLastActivityMiddleware:
|
||||||
cache.set(cache_key, now, timeout=self.UPDATE_INTERVAL * 2)
|
cache.set(cache_key, now, timeout=self.UPDATE_INTERVAL * 2)
|
||||||
|
|
||||||
# Обновляем объект пользователя в запросе для текущего запроса
|
# Обновляем объект пользователя в запросе для текущего запроса
|
||||||
# Это позволяет использовать обновленное значение в текущем запросе
|
|
||||||
user.last_activity = now
|
user.last_activity = now
|
||||||
|
|
||||||
|
# Учёт дня активности для реферальной программы (не чаще 1 раза в день на пользователя)
|
||||||
|
today = now.date()
|
||||||
|
day_cache_key = f'referral_activity_day:{user.id}:{today}'
|
||||||
|
if not cache.get(day_cache_key):
|
||||||
|
try:
|
||||||
|
from apps.referrals.models import UserActivityDay
|
||||||
|
UserActivityDay.objects.get_or_create(user=user, date=today)
|
||||||
|
cache.set(day_cache_key, 1, timeout=86400 * 2)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Логируем ошибку, но не прерываем выполнение запроса
|
# Логируем ошибку, но не прерываем выполнение запроса
|
||||||
logger.error(f"Ошибка при обновлении last_activity для пользователя {user.id}: {e}", exc_info=True)
|
logger.error(f"Ошибка при обновлении last_activity для пользователя {user.id}: {e}", exc_info=True)
|
||||||
|
|
|
||||||
|
|
@ -309,9 +309,37 @@ class User(AbstractUser):
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.get_full_name()} ({self.email})"
|
return f"{self.get_full_name()} ({self.email})"
|
||||||
|
|
||||||
|
def _generate_universal_code(self):
|
||||||
|
"""Генерация уникального 8-символьного кода (цифры + латинские буквы A–Z)."""
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
for _ in range(100):
|
||||||
|
code = ''.join(random.choices(alphabet, k=8))
|
||||||
|
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
|
||||||
|
return code
|
||||||
|
raise ValueError('Не удалось сгенерировать уникальный universal_code')
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if self.phone:
|
if self.phone:
|
||||||
self.phone = normalize_phone(self.phone)
|
self.phone = normalize_phone(self.phone)
|
||||||
|
|
||||||
|
# Автоматическая генерация username из email, если не задан
|
||||||
|
if not self.username and self.email:
|
||||||
|
self.username = self.email.split('@')[0]
|
||||||
|
# Добавляем цифры, если username уже существует
|
||||||
|
counter = 1
|
||||||
|
original_username = self.username
|
||||||
|
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
|
||||||
|
self.username = f"{original_username}{counter}"
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
# Гарантируем 8-символьный код (universal_code)
|
||||||
|
if not self.universal_code:
|
||||||
|
try:
|
||||||
|
self.universal_code = self._generate_universal_code()
|
||||||
|
except Exception:
|
||||||
|
# Если не удалось сгенерировать, не прерываем сохранение
|
||||||
|
pass
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -364,36 +392,8 @@ class Mentor(User):
|
||||||
def can_access_admin(self):
|
def can_access_admin(self):
|
||||||
"""Может ли пользователь получить доступ к админ-панели."""
|
"""Может ли пользователь получить доступ к админ-панели."""
|
||||||
return self.is_staff or self.is_superuser or self.role == 'admin'
|
return self.is_staff or self.is_superuser or self.role == 'admin'
|
||||||
|
|
||||||
def _generate_universal_code(self):
|
|
||||||
"""Генерация уникального 8-символьного кода (цифры + латинские буквы A–Z)."""
|
|
||||||
alphabet = string.ascii_uppercase + string.digits
|
|
||||||
for _ in range(100):
|
|
||||||
code = ''.join(random.choices(alphabet, k=8))
|
|
||||||
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
|
|
||||||
return code
|
|
||||||
raise ValueError('Не удалось сгенерировать уникальный universal_code')
|
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
# Мы удалили Mentor.save и _generate_universal_code, так как они теперь в User
|
||||||
"""Переопределение save для автоматической генерации username и universal_code."""
|
|
||||||
if not self.username:
|
|
||||||
# Генерируем username из email, если не задан
|
|
||||||
self.username = self.email.split('@')[0]
|
|
||||||
# Добавляем цифры, если username уже существует
|
|
||||||
counter = 1
|
|
||||||
original_username = self.username
|
|
||||||
while User.objects.filter(username=self.username).exclude(pk=self.pk).exists():
|
|
||||||
self.username = f"{original_username}{counter}"
|
|
||||||
counter += 1
|
|
||||||
if not self.universal_code:
|
|
||||||
try:
|
|
||||||
self.universal_code = self._generate_universal_code()
|
|
||||||
except Exception:
|
|
||||||
# Если не удалось сгенерировать, не прерываем сохранение
|
|
||||||
# Код будет сгенерирован при следующем запросе профиля или в RegisterView
|
|
||||||
pass
|
|
||||||
|
|
||||||
super().save(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class Client(models.Model):
|
class Client(models.Model):
|
||||||
|
|
|
||||||
|
|
@ -1505,6 +1505,27 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
city=city
|
city=city
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Гарантируем 8-символьный код для приглашений (ментор/студент)
|
||||||
|
if not student_user.universal_code or len(str(student_user.universal_code or '').strip()) != 8:
|
||||||
|
try:
|
||||||
|
# Теперь метод _generate_universal_code определен в базовой модели User
|
||||||
|
student_user.universal_code = student_user._generate_universal_code()
|
||||||
|
student_user.save(update_fields=['universal_code'])
|
||||||
|
except Exception:
|
||||||
|
# Fallback на случай ошибок генерации
|
||||||
|
import string
|
||||||
|
import random
|
||||||
|
try:
|
||||||
|
alphabet = string.ascii_uppercase + string.digits
|
||||||
|
for _ in range(500):
|
||||||
|
code = ''.join(random.choices(alphabet, k=8))
|
||||||
|
if not User.objects.filter(universal_code=code).exclude(pk=student_user.pk).exists():
|
||||||
|
student_user.universal_code = code
|
||||||
|
student_user.save(update_fields=['universal_code'])
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Генерируем персональный токен для входа
|
# Генерируем персональный токен для входа
|
||||||
student_user.login_token = secrets.token_urlsafe(32)
|
student_user.login_token = secrets.token_urlsafe(32)
|
||||||
student_user.save(update_fields=['login_token'])
|
student_user.save(update_fields=['login_token'])
|
||||||
|
|
@ -1538,6 +1559,8 @@ class InvitationViewSet(viewsets.ViewSet):
|
||||||
from rest_framework_simplejwt.tokens import RefreshToken
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
refresh = RefreshToken.for_user(student_user)
|
refresh = RefreshToken.for_user(student_user)
|
||||||
|
|
||||||
|
# Обновляем из БД, чтобы в ответе был актуальный universal_code
|
||||||
|
student_user.refresh_from_db()
|
||||||
return Response({
|
return Response({
|
||||||
'refresh': str(refresh),
|
'refresh': str(refresh),
|
||||||
'access': str(refresh.access_token),
|
'access': str(refresh.access_token),
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -391,10 +391,10 @@ REST_FRAMEWORK = {
|
||||||
'rest_framework.throttling.UserRateThrottle',
|
'rest_framework.throttling.UserRateThrottle',
|
||||||
],
|
],
|
||||||
'DEFAULT_THROTTLE_RATES': {
|
'DEFAULT_THROTTLE_RATES': {
|
||||||
'anon': '100/hour', # Для неавторизованных пользователей
|
'anon': '200/hour', # Для неавторизованных пользователей
|
||||||
'user': '1000/hour', # Для авторизованных пользователей
|
'user': '5000/hour', # Для авторизованных пользователей
|
||||||
'burst': '60/minute', # Для критичных endpoints (login, register)
|
'burst': '60/minute', # Для критичных endpoints (login, register)
|
||||||
'upload': '20/hour', # Для загрузки файлов
|
'upload': '60/hour', # Для загрузки файлов
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,28 +84,41 @@ RUN mkdir -p public
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
# Проверяем, что standalone создался (выводим структуру для отладки)
|
||||||
|
RUN echo "=== Checking standalone output ===" && \
|
||||||
|
ls -la /app/.next/ || echo "No .next directory" && \
|
||||||
|
ls -la /app/.next/standalone/ 2>/dev/null || echo "No standalone directory" && \
|
||||||
|
test -f /app/.next/standalone/server.js || (echo "ERROR: server.js not found in standalone" && ls -la /app/.next/standalone/ && exit 1)
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM node:20-alpine AS production
|
FROM node:20-alpine AS production
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
# Копируем собранное приложение (standalone mode)
|
# Копируем собранное приложение (standalone mode)
|
||||||
COPY --from=production-build /app/.next/standalone ./
|
# В Next.js standalone структура: /app/.next/standalone/ содержит server.js, .next/server/, node_modules/
|
||||||
COPY --from=production-build /app/.next/static ./.next/static
|
# Копируем всё содержимое standalone в корень /app
|
||||||
COPY --from=production-build /app/public ./public
|
COPY --from=production-build --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
# Статические файлы (standalone их не включает автоматически)
|
||||||
|
COPY --from=production-build --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
# Public файлы (standalone их не включает автоматически)
|
||||||
|
COPY --from=production-build --chown=nextjs:nodejs /app/public ./public
|
||||||
|
|
||||||
|
# Проверяем, что server.js существует
|
||||||
|
RUN test -f /app/server.js || (echo "ERROR: server.js not found after copy" && ls -la /app/ && exit 1)
|
||||||
|
|
||||||
# Создаем непривилегированного пользователя
|
# Создаем непривилегированного пользователя
|
||||||
RUN addgroup --system --gid 1001 nodejs
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
RUN adduser --system --uid 1001 nextjs
|
adduser --system --uid 1001 nextjs
|
||||||
RUN chown -R nextjs:nodejs /app
|
|
||||||
USER nextjs
|
USER nextjs
|
||||||
|
|
||||||
# Открываем порт
|
# Открываем порт
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME="0.0.0.0"
|
|
||||||
|
|
||||||
# Запускаем production server
|
# Запускаем production server
|
||||||
CMD ["node", "server.js"]
|
CMD ["node", "server.js"]
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,38 @@
|
||||||
/**
|
/**
|
||||||
* API для подписок и оплаты
|
* API для подписок и оплаты
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import apiClient from '@/lib/api-client';
|
import apiClient from '@/lib/api-client';
|
||||||
|
|
||||||
export interface Subscription {
|
export interface Subscription {
|
||||||
id: number;
|
id: number;
|
||||||
plan: { id: number; name: string };
|
plan: { id: number; name: string };
|
||||||
start_date: string;
|
start_date: string;
|
||||||
end_date: string;
|
end_date: string;
|
||||||
student_count?: number;
|
student_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getActiveSubscription(): Promise<Subscription | null> {
|
export async function getActiveSubscription(): Promise<Subscription | null> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<any>('/subscriptions/subscriptions/active/');
|
const response = await apiClient.get<any>('/subscriptions/subscriptions/active/');
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActivateFreeParams {
|
export interface ActivateFreeParams {
|
||||||
plan_id: number;
|
plan_id: number;
|
||||||
duration_days?: number;
|
duration_days?: number;
|
||||||
student_count?: number;
|
student_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Активировать бесплатный тариф (цена 0) без создания платежа */
|
/** Активировать бесплатный тариф (цена 0) без создания платежа */
|
||||||
export async function activateFreeSubscription(params: ActivateFreeParams): Promise<{ success: boolean; subscription: Subscription }> {
|
export async function activateFreeSubscription(params: ActivateFreeParams): Promise<{ success: boolean; subscription: Subscription }> {
|
||||||
const url = '/subscriptions/subscriptions/activate_free/';
|
const url = '/subscriptions/subscriptions/activate_free/';
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
console.log('[API] POST', url, params);
|
console.log('[API] POST', url, params);
|
||||||
}
|
}
|
||||||
const response = await apiClient.post<{ success: boolean; subscription: Subscription }>(url, params);
|
const response = await apiClient.post<{ success: boolean; subscription: Subscription }>(url, params);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export default function AuthLayout({
|
||||||
return (
|
return (
|
||||||
<AuthRedirect>
|
<AuthRedirect>
|
||||||
<div
|
<div
|
||||||
|
className="auth-layout"
|
||||||
data-no-nav
|
data-no-nav
|
||||||
style={{
|
style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
|
|
@ -17,6 +18,7 @@ export default function AuthLayout({
|
||||||
>
|
>
|
||||||
{/* Левая колонка — пустая, фон как у body */}
|
{/* Левая колонка — пустая, фон как у body */}
|
||||||
<div
|
<div
|
||||||
|
className="auth-layout-logo"
|
||||||
style={{
|
style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|
@ -38,6 +40,7 @@ export default function AuthLayout({
|
||||||
|
|
||||||
{/* Правая колонка — форма на белом фоне */}
|
{/* Правая колонка — форма на белом фоне */}
|
||||||
<div
|
<div
|
||||||
|
className="auth-layout-form"
|
||||||
style={{
|
style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,7 @@ export default function RegisterPage() {
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div
|
<div
|
||||||
|
className="auth-name-grid"
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '1fr 1fr',
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
|
|
||||||
|
|
@ -1,236 +1,270 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { Box, Typography } from '@mui/material';
|
import { Box, Typography } from '@mui/material';
|
||||||
import { getConversations, getChatById } from '@/api/chat';
|
import { getConversations, getChatById } from '@/api/chat';
|
||||||
import type { Chat } from '@/api/chat';
|
import type { Chat } from '@/api/chat';
|
||||||
import { ChatList } from '@/components/chat/ChatList';
|
import { ChatList } from '@/components/chat/ChatList';
|
||||||
import { ChatWindow } from '@/components/chat/ChatWindow';
|
import { ChatWindow } from '@/components/chat/ChatWindow';
|
||||||
import { usePresenceWebSocket } from '@/hooks/usePresenceWebSocket';
|
import { usePresenceWebSocket } from '@/hooks/usePresenceWebSocket';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
|
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
export default function ChatPage() {
|
|
||||||
const { user } = useAuth();
|
export default function ChatPage() {
|
||||||
const router = useRouter();
|
const { user } = useAuth();
|
||||||
const pathname = usePathname();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const pathname = usePathname();
|
||||||
const uuidFromUrl = searchParams.get('uuid');
|
const searchParams = useSearchParams();
|
||||||
|
const uuidFromUrl = searchParams.get('uuid');
|
||||||
const [loading, setLoading] = React.useState(true);
|
const isMobile = useIsMobile();
|
||||||
const [chats, setChats] = React.useState<Chat[]>([]);
|
|
||||||
const [selected, setSelected] = React.useState<Chat | null>(null);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [error, setError] = React.useState<string | null>(null);
|
const [chats, setChats] = React.useState<Chat[]>([]);
|
||||||
const [hasMore, setHasMore] = React.useState(false);
|
const [selected, setSelected] = React.useState<Chat | null>(null);
|
||||||
const [page, setPage] = React.useState(1);
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [loadingMore, setLoadingMore] = React.useState(false);
|
const [hasMore, setHasMore] = React.useState(false);
|
||||||
usePresenceWebSocket({ enabled: true });
|
const [page, setPage] = React.useState(1);
|
||||||
const refreshNavBadges = useNavBadgesRefresh();
|
const [loadingMore, setLoadingMore] = React.useState(false);
|
||||||
|
usePresenceWebSocket({ enabled: true });
|
||||||
// На странице чата не должно быть общего скролла (скроллим только панели внутри)
|
const refreshNavBadges = useNavBadgesRefresh();
|
||||||
React.useEffect(() => {
|
|
||||||
const prevHtml = document.documentElement.style.overflow;
|
// На странице чата не должно быть общего скролла (скроллим только панели внутри)
|
||||||
const prevBody = document.body.style.overflow;
|
React.useEffect(() => {
|
||||||
document.documentElement.style.overflow = 'hidden';
|
const prevHtml = document.documentElement.style.overflow;
|
||||||
document.body.style.overflow = 'hidden';
|
const prevBody = document.body.style.overflow;
|
||||||
return () => {
|
document.documentElement.style.overflow = 'hidden';
|
||||||
document.documentElement.style.overflow = prevHtml;
|
document.body.style.overflow = 'hidden';
|
||||||
document.body.style.overflow = prevBody;
|
return () => {
|
||||||
};
|
document.documentElement.style.overflow = prevHtml;
|
||||||
}, []);
|
document.body.style.overflow = prevBody;
|
||||||
|
};
|
||||||
const normalizeChat = React.useCallback((c: any) => {
|
}, []);
|
||||||
const otherName =
|
|
||||||
c?.other_participant?.full_name ||
|
const normalizeChat = React.useCallback((c: any) => {
|
||||||
[c?.other_participant?.first_name, c?.other_participant?.last_name].filter(Boolean).join(' ') ||
|
const otherName =
|
||||||
c?.participant_name ||
|
c?.other_participant?.full_name ||
|
||||||
'Чат';
|
[c?.other_participant?.first_name, c?.other_participant?.last_name].filter(Boolean).join(' ') ||
|
||||||
const avatarUrl = c?.other_participant?.avatar_url || c?.other_participant?.avatar || null;
|
c?.participant_name ||
|
||||||
const otherId = c?.other_participant?.id ?? null;
|
'Чат';
|
||||||
const otherOnline = !!c?.other_participant?.is_online;
|
const avatarUrl = c?.other_participant?.avatar_url || c?.other_participant?.avatar || null;
|
||||||
const otherLast = c?.other_participant?.last_activity ?? null;
|
const otherId = c?.other_participant?.id ?? null;
|
||||||
const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || '';
|
const otherOnline = !!c?.other_participant?.is_online;
|
||||||
const unread = c?.my_participant?.unread_count ?? c?.unread_count ?? 0;
|
const otherLast = c?.other_participant?.last_activity ?? null;
|
||||||
return {
|
const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || '';
|
||||||
id: c.id,
|
const unread = c?.my_participant?.unread_count ?? c?.unread_count ?? 0;
|
||||||
uuid: c.uuid,
|
return {
|
||||||
participant_name: otherName,
|
id: c.id,
|
||||||
avatar_url: avatarUrl,
|
uuid: c.uuid,
|
||||||
other_user_id: otherId,
|
participant_name: otherName,
|
||||||
other_is_online: otherOnline,
|
avatar_url: avatarUrl,
|
||||||
other_last_activity: otherLast,
|
other_user_id: otherId,
|
||||||
last_message: lastText,
|
other_is_online: otherOnline,
|
||||||
unread_count: unread,
|
other_last_activity: otherLast,
|
||||||
created_at: c.created_at,
|
last_message: lastText,
|
||||||
};
|
unread_count: unread,
|
||||||
}, []);
|
created_at: c.created_at,
|
||||||
|
};
|
||||||
React.useEffect(() => {
|
}, []);
|
||||||
(async () => {
|
|
||||||
try {
|
React.useEffect(() => {
|
||||||
setLoading(true);
|
(async () => {
|
||||||
setError(null);
|
try {
|
||||||
const resp = await getConversations({ page: 1, page_size: 30 });
|
setLoading(true);
|
||||||
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
|
setError(null);
|
||||||
setChats(normalized as any);
|
const resp = await getConversations({ page: 1, page_size: 30 });
|
||||||
setHasMore(!!(resp as any).next);
|
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
|
||||||
setPage(1);
|
setChats(normalized as any);
|
||||||
} catch (e: any) {
|
setHasMore(!!(resp as any).next);
|
||||||
console.error('[ChatPage] Ошибка загрузки чатов:', e);
|
setPage(1);
|
||||||
const msg =
|
} catch (e: any) {
|
||||||
e?.response?.data?.detail ||
|
console.error('[ChatPage] Ошибка загрузки чатов:', e);
|
||||||
e?.response?.data?.error ||
|
const msg =
|
||||||
e?.message ||
|
e?.response?.data?.detail ||
|
||||||
'Не удалось загрузить чаты';
|
e?.response?.data?.error ||
|
||||||
setError(String(msg));
|
e?.message ||
|
||||||
} finally {
|
'Не удалось загрузить чаты';
|
||||||
setLoading(false);
|
setError(String(msg));
|
||||||
}
|
} finally {
|
||||||
})();
|
setLoading(false);
|
||||||
}, [normalizeChat]);
|
}
|
||||||
|
})();
|
||||||
const restoredForUuidRef = React.useRef<string | null>(null);
|
}, [normalizeChat]);
|
||||||
|
|
||||||
// Восстановить выбранный чат из URL после загрузки списка (или по uuid)
|
const restoredForUuidRef = React.useRef<string | null>(null);
|
||||||
React.useEffect(() => {
|
|
||||||
if (loading || error || !uuidFromUrl) return;
|
// Восстановить выбранный чат из URL после загрузки списка (или по uuid)
|
||||||
if (restoredForUuidRef.current === uuidFromUrl) return;
|
React.useEffect(() => {
|
||||||
const found = chats.find((c) => (c as any).uuid === uuidFromUrl);
|
if (loading || error || !uuidFromUrl) return;
|
||||||
if (found) {
|
if (restoredForUuidRef.current === uuidFromUrl) return;
|
||||||
setSelected(found as Chat);
|
const found = chats.find((c) => (c as any).uuid === uuidFromUrl);
|
||||||
restoredForUuidRef.current = uuidFromUrl;
|
if (found) {
|
||||||
return;
|
setSelected(found as Chat);
|
||||||
}
|
restoredForUuidRef.current = uuidFromUrl;
|
||||||
(async () => {
|
return;
|
||||||
try {
|
}
|
||||||
const c = await getChatById(uuidFromUrl);
|
(async () => {
|
||||||
const normalized = normalizeChat(c) as any;
|
try {
|
||||||
setSelected(normalized as Chat);
|
const c = await getChatById(uuidFromUrl);
|
||||||
restoredForUuidRef.current = uuidFromUrl;
|
const normalized = normalizeChat(c) as any;
|
||||||
} catch (e: any) {
|
setSelected(normalized as Chat);
|
||||||
console.warn('[ChatPage] Чат по uuid из URL не найден:', uuidFromUrl, e);
|
restoredForUuidRef.current = uuidFromUrl;
|
||||||
restoredForUuidRef.current = null;
|
} catch (e: any) {
|
||||||
router.replace(pathname ?? '/chat');
|
console.warn('[ChatPage] Чат по uuid из URL не найден:', uuidFromUrl, e);
|
||||||
}
|
restoredForUuidRef.current = null;
|
||||||
})();
|
router.replace(pathname ?? '/chat');
|
||||||
}, [loading, error, uuidFromUrl, chats, normalizeChat, router, pathname]);
|
}
|
||||||
|
})();
|
||||||
React.useEffect(() => {
|
}, [loading, error, uuidFromUrl, chats, normalizeChat, router, pathname]);
|
||||||
if (!uuidFromUrl) restoredForUuidRef.current = null;
|
|
||||||
}, [uuidFromUrl]);
|
React.useEffect(() => {
|
||||||
|
if (!uuidFromUrl) restoredForUuidRef.current = null;
|
||||||
const handleSelectChat = React.useCallback(
|
}, [uuidFromUrl]);
|
||||||
(c: Chat) => {
|
|
||||||
setSelected(c);
|
const handleSelectChat = React.useCallback(
|
||||||
const u = (c as any).uuid;
|
(c: Chat) => {
|
||||||
if (u) {
|
setSelected(c);
|
||||||
const base = pathname ?? '/chat';
|
const u = (c as any).uuid;
|
||||||
router.replace(`${base}?uuid=${encodeURIComponent(u)}`);
|
if (u) {
|
||||||
}
|
const base = pathname ?? '/chat';
|
||||||
},
|
router.replace(`${base}?uuid=${encodeURIComponent(u)}`);
|
||||||
[router, pathname]
|
}
|
||||||
);
|
},
|
||||||
|
[router, pathname]
|
||||||
const loadMore = React.useCallback(async () => {
|
);
|
||||||
if (loadingMore || !hasMore) return;
|
|
||||||
try {
|
const loadMore = React.useCallback(async () => {
|
||||||
setLoadingMore(true);
|
if (loadingMore || !hasMore) return;
|
||||||
const next = page + 1;
|
try {
|
||||||
const resp = await getConversations({ page: next, page_size: 30 });
|
setLoadingMore(true);
|
||||||
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
|
const next = page + 1;
|
||||||
setChats((prev) => [...prev, ...(normalized as any)]);
|
const resp = await getConversations({ page: next, page_size: 30 });
|
||||||
setHasMore(!!(resp as any).next);
|
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
|
||||||
setPage(next);
|
setChats((prev) => [...prev, ...(normalized as any)]);
|
||||||
} catch (e: any) {
|
setHasMore(!!(resp as any).next);
|
||||||
console.error('[ChatPage] Ошибка загрузки чатов:', e);
|
setPage(next);
|
||||||
} finally {
|
} catch (e: any) {
|
||||||
setLoadingMore(false);
|
console.error('[ChatPage] Ошибка загрузки чатов:', e);
|
||||||
}
|
} finally {
|
||||||
}, [page, hasMore, loadingMore, normalizeChat]);
|
setLoadingMore(false);
|
||||||
|
}
|
||||||
const refreshChatListUnread = React.useCallback(async () => {
|
}, [page, hasMore, loadingMore, normalizeChat]);
|
||||||
try {
|
|
||||||
const resp = await getConversations({ page: 1, page_size: 30 });
|
const refreshChatListUnread = React.useCallback(async () => {
|
||||||
const fresh = (resp.results || []).map((c: any) => normalizeChat(c)) as Chat[];
|
try {
|
||||||
const freshByUuid = new Map(fresh.map((c: any) => [(c as any).uuid, c]));
|
const resp = await getConversations({ page: 1, page_size: 30 });
|
||||||
setChats((prev) =>
|
const fresh = (resp.results || []).map((c: any) => normalizeChat(c)) as Chat[];
|
||||||
prev.map((c: any) => {
|
const freshByUuid = new Map(fresh.map((c: any) => [(c as any).uuid, c]));
|
||||||
const updated = freshByUuid.get(c.uuid);
|
setChats((prev) =>
|
||||||
return updated ? (updated as Chat) : c;
|
prev.map((c: any) => {
|
||||||
})
|
const updated = freshByUuid.get(c.uuid);
|
||||||
);
|
return updated ? (updated as Chat) : c;
|
||||||
await refreshNavBadges?.();
|
})
|
||||||
} catch {
|
);
|
||||||
// ignore
|
await refreshNavBadges?.();
|
||||||
}
|
} catch {
|
||||||
}, [normalizeChat, refreshNavBadges]);
|
// ignore
|
||||||
|
}
|
||||||
return (
|
}, [normalizeChat, refreshNavBadges]);
|
||||||
<div className="ios26-dashboard ios26-chat-page" style={{ padding: '16px' }}>
|
|
||||||
<Box
|
const handleBackToList = React.useCallback(() => {
|
||||||
className="ios26-chat-layout"
|
setSelected(null);
|
||||||
sx={{
|
router.replace(pathname ?? '/chat');
|
||||||
display: 'grid',
|
}, [router, pathname]);
|
||||||
gridTemplateColumns: '320px 1fr',
|
|
||||||
gap: 'var(--ios26-spacing)',
|
// Mobile: show only list or only chat
|
||||||
alignItems: 'stretch',
|
const mobileShowChat = isMobile && selected != null;
|
||||||
height: 'calc(90vh - 32px)',
|
|
||||||
maxHeight: 'calc(90vh - 32px)',
|
// Hide bottom navigation when a chat is open on mobile
|
||||||
overflow: 'hidden',
|
React.useEffect(() => {
|
||||||
}}
|
if (mobileShowChat) {
|
||||||
>
|
document.documentElement.classList.add('mobile-chat-open');
|
||||||
{loading ? (
|
} else {
|
||||||
<Box
|
document.documentElement.classList.remove('mobile-chat-open');
|
||||||
className="ios-glass-panel"
|
}
|
||||||
sx={{
|
return () => {
|
||||||
borderRadius: '20px',
|
document.documentElement.classList.remove('mobile-chat-open');
|
||||||
p: 2,
|
};
|
||||||
display: 'flex',
|
}, [mobileShowChat]);
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
return (
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
<div className="ios26-dashboard ios26-chat-page" style={{ padding: isMobile ? '8px' : '16px' }}>
|
||||||
background: 'var(--ios26-glass)',
|
<Box
|
||||||
border: '1px solid var(--ios26-glass-border)',
|
className="ios26-chat-layout"
|
||||||
backdropFilter: 'var(--ios26-blur)',
|
sx={{
|
||||||
}}
|
display: isMobile ? 'flex' : 'grid',
|
||||||
>
|
gridTemplateColumns: isMobile ? undefined : '320px 1fr',
|
||||||
<Typography>Загрузка…</Typography>
|
flexDirection: isMobile ? 'column' : undefined,
|
||||||
</Box>
|
gap: 'var(--ios26-spacing)',
|
||||||
) : error ? (
|
alignItems: 'stretch',
|
||||||
<Box
|
height: isMobile ? '100%' : 'calc(90vh - 32px)',
|
||||||
className="ios-glass-panel"
|
maxHeight: isMobile ? undefined : 'calc(90vh - 32px)',
|
||||||
sx={{
|
overflow: 'hidden',
|
||||||
borderRadius: '20px',
|
}}
|
||||||
p: 2,
|
>
|
||||||
display: 'flex',
|
{/* Chat list: hidden on mobile when a chat is selected */}
|
||||||
alignItems: 'center',
|
{!mobileShowChat && (
|
||||||
justifyContent: 'center',
|
<>
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
{loading ? (
|
||||||
background: 'var(--ios26-glass)',
|
<Box
|
||||||
border: '1px solid var(--ios26-glass-border)',
|
className="ios-glass-panel"
|
||||||
backdropFilter: 'var(--ios26-blur)',
|
sx={{
|
||||||
}}
|
borderRadius: '20px',
|
||||||
>
|
p: 2,
|
||||||
<Typography>{error}</Typography>
|
display: 'flex',
|
||||||
</Box>
|
flex: isMobile ? 1 : undefined,
|
||||||
) : (
|
alignItems: 'center',
|
||||||
<ChatList
|
justifyContent: 'center',
|
||||||
chats={chats}
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
selectedChatUuid={selected?.uuid ?? (selected as any)?.uuid ?? null}
|
background: 'var(--ios26-glass)',
|
||||||
onSelect={handleSelectChat}
|
border: '1px solid var(--ios26-glass-border)',
|
||||||
hasMore={hasMore}
|
backdropFilter: 'var(--ios26-blur)',
|
||||||
loadingMore={loadingMore}
|
}}
|
||||||
onLoadMore={loadMore}
|
>
|
||||||
/>
|
<Typography>Загрузка…</Typography>
|
||||||
)}
|
</Box>
|
||||||
|
) : error ? (
|
||||||
<ChatWindow
|
<Box
|
||||||
chat={selected}
|
className="ios-glass-panel"
|
||||||
currentUserId={user?.id ?? null}
|
sx={{
|
||||||
onMessagesMarkedAsRead={refreshChatListUnread}
|
borderRadius: '20px',
|
||||||
/>
|
p: 2,
|
||||||
</Box>
|
display: 'flex',
|
||||||
</div>
|
flex: isMobile ? 1 : undefined,
|
||||||
);
|
alignItems: 'center',
|
||||||
}
|
justifyContent: 'center',
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
background: 'var(--ios26-glass)',
|
||||||
|
border: '1px solid var(--ios26-glass-border)',
|
||||||
|
backdropFilter: 'var(--ios26-blur)',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography>{error}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<ChatList
|
||||||
|
chats={chats}
|
||||||
|
selectedChatUuid={selected?.uuid ?? (selected as any)?.uuid ?? null}
|
||||||
|
onSelect={handleSelectChat}
|
||||||
|
hasMore={hasMore}
|
||||||
|
loadingMore={loadingMore}
|
||||||
|
onLoadMore={loadMore}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Chat window: on mobile only visible when a chat is selected */}
|
||||||
|
{(!isMobile || mobileShowChat) && (
|
||||||
|
<ChatWindow
|
||||||
|
chat={selected}
|
||||||
|
currentUserId={user?.id ?? null}
|
||||||
|
onBack={isMobile ? handleBackToList : undefined}
|
||||||
|
onMessagesMarkedAsRead={refreshChatListUnread}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,7 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
import { useEffect, useState, useCallback, Suspense } from 'react';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
const MOBILE_BREAKPOINT = 767;
|
|
||||||
function useIsMobile() {
|
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`);
|
|
||||||
setIsMobile(mq.matches);
|
|
||||||
const listener = () => setIsMobile(mq.matches);
|
|
||||||
mq.addEventListener('change', listener);
|
|
||||||
return () => mq.removeEventListener('change', listener);
|
|
||||||
}, []);
|
|
||||||
return isMobile;
|
|
||||||
}
|
|
||||||
import { useRouter, usePathname } from 'next/navigation';
|
import { useRouter, usePathname } from 'next/navigation';
|
||||||
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
|
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
|
||||||
import { TopNavigationBar } from '@/components/navigation/TopNavigationBar';
|
import { TopNavigationBar } from '@/components/navigation/TopNavigationBar';
|
||||||
|
|
@ -91,9 +79,9 @@ export default function ProtectedLayout({
|
||||||
|
|
||||||
console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname });
|
console.log('[ProtectedLayout] Auth state:', { user: !!user, loading, hasToken: !!token, pathname });
|
||||||
|
|
||||||
if (!loading && !user && !token) {
|
if (!loading && !user) {
|
||||||
console.log('[ProtectedLayout] Redirecting to login');
|
console.log('[ProtectedLayout] No user found, redirecting to login');
|
||||||
router.push('/login');
|
router.replace('/login');
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [user, loading]);
|
}, [user, loading]);
|
||||||
|
|
@ -114,7 +102,14 @@ export default function ProtectedLayout({
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null;
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
|
// Ментор не на /payment: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
|
||||||
|
|
@ -153,24 +148,13 @@ export default function ProtectedLayout({
|
||||||
return (
|
return (
|
||||||
<NavBadgesProvider refreshNavBadges={refreshNavBadges}>
|
<NavBadgesProvider refreshNavBadges={refreshNavBadges}>
|
||||||
<SelectedChildProvider>
|
<SelectedChildProvider>
|
||||||
<div
|
<div className="protected-layout-root">
|
||||||
className="protected-layout-root"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minHeight: '100vh',
|
|
||||||
height: '100vh',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{!isFullWidthPage && <TopNavigationBar user={user} />}
|
{!isFullWidthPage && <TopNavigationBar user={user} />}
|
||||||
<main
|
<main
|
||||||
className="protected-main"
|
className="protected-main"
|
||||||
data-no-nav={isLiveKit ? true : undefined}
|
data-no-nav={isLiveKit ? true : undefined}
|
||||||
data-full-width={isFullWidthPage ? true : undefined}
|
data-full-width={isFullWidthPage ? true : undefined}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
|
||||||
minHeight: 0,
|
|
||||||
overflow: 'auto',
|
|
||||||
padding: isFullWidthPage ? '0' : '16px',
|
padding: isFullWidthPage ? '0' : '16px',
|
||||||
maxWidth: isFullWidthPage ? '100%' : '1200px',
|
maxWidth: isFullWidthPage ? '100%' : '1200px',
|
||||||
margin: isFullWidthPage ? '0' : '0 auto',
|
margin: isFullWidthPage ? '0' : '0 auto',
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,426 +1,426 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from 'react';
|
import { useEffect, useState, useMemo } from 'react';
|
||||||
import { getLessons, type Lesson } from '@/api/schedule';
|
import { getLessons, type Lesson } from '@/api/schedule';
|
||||||
import { getHomeworkSubmissionsBySubject, type HomeworkSubmission } from '@/api/homework';
|
import { getHomeworkSubmissionsBySubject, type HomeworkSubmission } from '@/api/homework';
|
||||||
import { format, subMonths, startOfDay, endOfDay, addDays } from 'date-fns';
|
import { format, subMonths, startOfDay, endOfDay, addDays } from 'date-fns';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
||||||
import { DashboardLayout, Panel, SectionHeader } from '@/components/dashboard/ui';
|
import { DashboardLayout, Panel, SectionHeader } from '@/components/dashboard/ui';
|
||||||
import { DateRangePicker } from '@/components/common/DateRangePicker';
|
import { DateRangePicker } from '@/components/common/DateRangePicker';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
|
||||||
const Chart = dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
|
const Chart = dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
<LoadingSpinner size="medium" />
|
<LoadingSpinner size="medium" />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
const CHART_COLORS = ['#6750A4', '#7D5260'];
|
const CHART_COLORS = ['#6750A4', '#7D5260'];
|
||||||
|
|
||||||
const defaultRange = {
|
const defaultRange = {
|
||||||
start_date: dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
|
start_date: dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
|
||||||
end_date: dayjs().format('YYYY-MM-DD'),
|
end_date: dayjs().format('YYYY-MM-DD'),
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSubjectFromLesson(lesson: Lesson): string {
|
function getSubjectFromLesson(lesson: Lesson): string {
|
||||||
if (typeof lesson.subject === 'string' && lesson.subject?.trim()) return lesson.subject.trim();
|
if (typeof lesson.subject === 'string' && lesson.subject?.trim()) return lesson.subject.trim();
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Все даты в диапазоне [startStr, endStr] включительно (формат YYYY-MM-DD) */
|
/** Все даты в диапазоне [startStr, endStr] включительно (формат YYYY-MM-DD) */
|
||||||
function getDatesInRange(startStr: string, endStr: string): string[] {
|
function getDatesInRange(startStr: string, endStr: string): string[] {
|
||||||
const dates: string[] = [];
|
const dates: string[] = [];
|
||||||
let d = new Date(startStr);
|
let d = new Date(startStr);
|
||||||
const end = new Date(endStr);
|
const end = new Date(endStr);
|
||||||
while (d <= end) {
|
while (d <= end) {
|
||||||
dates.push(format(d, 'yyyy-MM-dd'));
|
dates.push(format(d, 'yyyy-MM-dd'));
|
||||||
d = addDays(d, 1);
|
d = addDays(d, 1);
|
||||||
}
|
}
|
||||||
return dates;
|
return dates;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MyProgressPage() {
|
export default function MyProgressPage() {
|
||||||
const { selectedChild } = useSelectedChild();
|
const { selectedChild } = useSelectedChild();
|
||||||
const [dateRangeValue, setDateRangeValue] = useState<{ start_date: string; end_date: string }>(() => defaultRange);
|
const [dateRangeValue, setDateRangeValue] = useState<{ start_date: string; end_date: string }>(() => defaultRange);
|
||||||
const [selectedSubject, setSelectedSubject] = useState<string>('');
|
const [selectedSubject, setSelectedSubject] = useState<string>('');
|
||||||
|
|
||||||
const dateRange = useMemo(() => ({
|
const dateRange = useMemo(() => ({
|
||||||
start: startOfDay(new Date(dateRangeValue.start_date)),
|
start: startOfDay(new Date(dateRangeValue.start_date)),
|
||||||
end: endOfDay(new Date(dateRangeValue.end_date)),
|
end: endOfDay(new Date(dateRangeValue.end_date)),
|
||||||
}), [dateRangeValue.start_date, dateRangeValue.end_date]);
|
}), [dateRangeValue.start_date, dateRangeValue.end_date]);
|
||||||
|
|
||||||
const startStr = format(dateRange.start, 'yyyy-MM-dd');
|
const startStr = format(dateRange.start, 'yyyy-MM-dd');
|
||||||
const endStr = format(dateRange.end, 'yyyy-MM-dd');
|
const endStr = format(dateRange.end, 'yyyy-MM-dd');
|
||||||
|
|
||||||
const [subjectsFromLessons, setSubjectsFromLessons] = useState<string[]>([]);
|
const [subjectsFromLessons, setSubjectsFromLessons] = useState<string[]>([]);
|
||||||
const [subjectsLoading, setSubjectsLoading] = useState(true);
|
const [subjectsLoading, setSubjectsLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setSubjectsLoading(true);
|
setSubjectsLoading(true);
|
||||||
getLessons({
|
getLessons({
|
||||||
start_date: format(subMonths(new Date(), 24), 'yyyy-MM-dd'),
|
start_date: format(subMonths(new Date(), 24), 'yyyy-MM-dd'),
|
||||||
end_date: format(new Date(), 'yyyy-MM-dd'),
|
end_date: format(new Date(), 'yyyy-MM-dd'),
|
||||||
...(selectedChild?.id && { child_id: selectedChild.id }),
|
...(selectedChild?.id && { child_id: selectedChild.id }),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const set = new Set<string>();
|
const set = new Set<string>();
|
||||||
(res.results || []).forEach((l: Lesson) => {
|
(res.results || []).forEach((l: Lesson) => {
|
||||||
const sub = getSubjectFromLesson(l);
|
const sub = getSubjectFromLesson(l);
|
||||||
if (sub) set.add(sub);
|
if (sub) set.add(sub);
|
||||||
});
|
});
|
||||||
const list = Array.from(set).sort();
|
const list = Array.from(set).sort();
|
||||||
setSubjectsFromLessons(list);
|
setSubjectsFromLessons(list);
|
||||||
if (list.length > 0 && !selectedSubject) setSelectedSubject(list[0]);
|
if (list.length > 0 && !selectedSubject) setSelectedSubject(list[0]);
|
||||||
})
|
})
|
||||||
.catch(() => { if (!cancelled) setSubjectsFromLessons([]); })
|
.catch(() => { if (!cancelled) setSubjectsFromLessons([]); })
|
||||||
.finally(() => { if (!cancelled) setSubjectsLoading(false); });
|
.finally(() => { if (!cancelled) setSubjectsLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (subjectsFromLessons.length > 0 && !selectedSubject) setSelectedSubject(subjectsFromLessons[0]);
|
if (subjectsFromLessons.length > 0 && !selectedSubject) setSelectedSubject(subjectsFromLessons[0]);
|
||||||
}, [subjectsFromLessons, selectedSubject]);
|
}, [subjectsFromLessons, selectedSubject]);
|
||||||
|
|
||||||
const [lessons, setLessons] = useState<Lesson[]>([]);
|
const [lessons, setLessons] = useState<Lesson[]>([]);
|
||||||
const [lessonsLoading, setLessonsLoading] = useState(false);
|
const [lessonsLoading, setLessonsLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!startStr || !endStr) return;
|
if (!startStr || !endStr) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setLessonsLoading(true);
|
setLessonsLoading(true);
|
||||||
getLessons({
|
getLessons({
|
||||||
start_date: startStr,
|
start_date: startStr,
|
||||||
end_date: endStr,
|
end_date: endStr,
|
||||||
...(selectedChild?.id && { child_id: selectedChild.id }),
|
...(selectedChild?.id && { child_id: selectedChild.id }),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const list = (res.results || []).filter((l: Lesson) => {
|
const list = (res.results || []).filter((l: Lesson) => {
|
||||||
const sub = getSubjectFromLesson(l);
|
const sub = getSubjectFromLesson(l);
|
||||||
return selectedSubject ? sub === selectedSubject : true;
|
return selectedSubject ? sub === selectedSubject : true;
|
||||||
});
|
});
|
||||||
list.sort((a: Lesson, b: Lesson) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
list.sort((a: Lesson, b: Lesson) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
|
||||||
setLessons(list);
|
setLessons(list);
|
||||||
})
|
})
|
||||||
.catch(() => { if (!cancelled) setLessons([]); })
|
.catch(() => { if (!cancelled) setLessons([]); })
|
||||||
.finally(() => { if (!cancelled) setLessonsLoading(false); });
|
.finally(() => { if (!cancelled) setLessonsLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [startStr, endStr, selectedSubject, selectedChild?.id]);
|
}, [startStr, endStr, selectedSubject, selectedChild?.id]);
|
||||||
|
|
||||||
const [homeworkSubmissions, setHomeworkSubmissions] = useState<HomeworkSubmission[]>([]);
|
const [homeworkSubmissions, setHomeworkSubmissions] = useState<HomeworkSubmission[]>([]);
|
||||||
const [homeworkLoading, setHomeworkLoading] = useState(false);
|
const [homeworkLoading, setHomeworkLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedSubject) {
|
if (!selectedSubject) {
|
||||||
setHomeworkSubmissions([]);
|
setHomeworkSubmissions([]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setHomeworkLoading(true);
|
setHomeworkLoading(true);
|
||||||
getHomeworkSubmissionsBySubject({
|
getHomeworkSubmissionsBySubject({
|
||||||
subject: selectedSubject,
|
subject: selectedSubject,
|
||||||
start_date: startStr,
|
start_date: startStr,
|
||||||
end_date: endStr,
|
end_date: endStr,
|
||||||
...(selectedChild?.id && { child_id: selectedChild.id }),
|
...(selectedChild?.id && { child_id: selectedChild.id }),
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setHomeworkSubmissions(res.results || []);
|
setHomeworkSubmissions(res.results || []);
|
||||||
})
|
})
|
||||||
.catch(() => { if (!cancelled) setHomeworkSubmissions([]); })
|
.catch(() => { if (!cancelled) setHomeworkSubmissions([]); })
|
||||||
.finally(() => { if (!cancelled) setHomeworkLoading(false); });
|
.finally(() => { if (!cancelled) setHomeworkLoading(false); });
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [selectedSubject, startStr, endStr, selectedChild?.id]);
|
}, [selectedSubject, startStr, endStr, selectedChild?.id]);
|
||||||
|
|
||||||
const periodStats = useMemo(() => {
|
const periodStats = useMemo(() => {
|
||||||
const completed = lessons.filter((l) => l.status === 'completed').length;
|
const completed = lessons.filter((l) => l.status === 'completed').length;
|
||||||
const total = lessons.length;
|
const total = lessons.length;
|
||||||
const cancelled = lessons.filter((l) => l.status === 'cancelled').length;
|
const cancelled = lessons.filter((l) => l.status === 'cancelled').length;
|
||||||
const attendanceRate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
const attendanceRate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
const withGrades = lessons.filter((l) => l.status === 'completed' && (l.mentor_grade != null || l.school_grade != null));
|
const withGrades = lessons.filter((l) => l.status === 'completed' && (l.mentor_grade != null || l.school_grade != null));
|
||||||
let sum = 0;
|
let sum = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
withGrades.forEach((l) => {
|
withGrades.forEach((l) => {
|
||||||
if (l.mentor_grade != null) { sum += l.mentor_grade; count++; }
|
if (l.mentor_grade != null) { sum += l.mentor_grade; count++; }
|
||||||
if (l.school_grade != null) { sum += l.school_grade; count++; }
|
if (l.school_grade != null) { sum += l.school_grade; count++; }
|
||||||
});
|
});
|
||||||
const avgGrade = count > 0 ? Math.round((sum / count) * 10) / 10 : 0;
|
const avgGrade = count > 0 ? Math.round((sum / count) * 10) / 10 : 0;
|
||||||
|
|
||||||
const hwGraded = homeworkSubmissions.filter((s) => s.score != null && s.checked_at).length;
|
const hwGraded = homeworkSubmissions.filter((s) => s.score != null && s.checked_at).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
completedLessons: completed,
|
completedLessons: completed,
|
||||||
totalLessons: total,
|
totalLessons: total,
|
||||||
attendanceRate,
|
attendanceRate,
|
||||||
cancelled,
|
cancelled,
|
||||||
avgGrade,
|
avgGrade,
|
||||||
hwGraded,
|
hwGraded,
|
||||||
};
|
};
|
||||||
}, [lessons, homeworkSubmissions]);
|
}, [lessons, homeworkSubmissions]);
|
||||||
|
|
||||||
const gradesChart = useMemo(() => {
|
const gradesChart = useMemo(() => {
|
||||||
const allDates = getDatesInRange(startStr, endStr);
|
const allDates = getDatesInRange(startStr, endStr);
|
||||||
const categories = allDates.map((d) => {
|
const categories = allDates.map((d) => {
|
||||||
const [, m, day] = d.split('-');
|
const [, m, day] = d.split('-');
|
||||||
return `${day}.${m}`;
|
return `${day}.${m}`;
|
||||||
});
|
});
|
||||||
const byDate: Record<string, { mentor: number | null; school: number | null }> = {};
|
const byDate: Record<string, { mentor: number | null; school: number | null }> = {};
|
||||||
lessons
|
lessons
|
||||||
.filter((l) => l.status === 'completed' && (l.mentor_grade != null || l.school_grade != null))
|
.filter((l) => l.status === 'completed' && (l.mentor_grade != null || l.school_grade != null))
|
||||||
.forEach((l) => {
|
.forEach((l) => {
|
||||||
const key = l.start_time?.slice(0, 10) || '';
|
const key = l.start_time?.slice(0, 10) || '';
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
byDate[key] = { mentor: l.mentor_grade ?? null, school: l.school_grade ?? null };
|
byDate[key] = { mentor: l.mentor_grade ?? null, school: l.school_grade ?? null };
|
||||||
});
|
});
|
||||||
const mentorGrades = allDates.map((d) => byDate[d]?.mentor ?? null);
|
const mentorGrades = allDates.map((d) => byDate[d]?.mentor ?? null);
|
||||||
const schoolGrades = allDates.map((d) => byDate[d]?.school ?? null);
|
const schoolGrades = allDates.map((d) => byDate[d]?.school ?? null);
|
||||||
return {
|
return {
|
||||||
series: [
|
series: [
|
||||||
{ name: 'Оценка репетитора', data: mentorGrades },
|
{ name: 'Оценка репетитора', data: mentorGrades },
|
||||||
{ name: 'Оценка в школе', data: schoolGrades },
|
{ name: 'Оценка в школе', data: schoolGrades },
|
||||||
],
|
],
|
||||||
categories,
|
categories,
|
||||||
};
|
};
|
||||||
}, [lessons, startStr, endStr]);
|
}, [lessons, startStr, endStr]);
|
||||||
|
|
||||||
const homeworkChart = useMemo(() => {
|
const homeworkChart = useMemo(() => {
|
||||||
const allDates = getDatesInRange(startStr, endStr);
|
const allDates = getDatesInRange(startStr, endStr);
|
||||||
const categories = allDates.map((d) => {
|
const categories = allDates.map((d) => {
|
||||||
const [, m, day] = d.split('-');
|
const [, m, day] = d.split('-');
|
||||||
return `${day}.${m}`;
|
return `${day}.${m}`;
|
||||||
});
|
});
|
||||||
const byDate: Record<string, number | null> = {};
|
const byDate: Record<string, number | null> = {};
|
||||||
homeworkSubmissions
|
homeworkSubmissions
|
||||||
.filter((s) => s.checked_at && s.score != null)
|
.filter((s) => s.checked_at && s.score != null)
|
||||||
.forEach((s) => {
|
.forEach((s) => {
|
||||||
const key = format(new Date(s.checked_at!), 'yyyy-MM-dd');
|
const key = format(new Date(s.checked_at!), 'yyyy-MM-dd');
|
||||||
byDate[key] = s.score ?? null;
|
byDate[key] = s.score ?? null;
|
||||||
});
|
});
|
||||||
const scores = allDates.map((d) => byDate[d] ?? null);
|
const scores = allDates.map((d) => byDate[d] ?? null);
|
||||||
return {
|
return {
|
||||||
series: [{ name: 'Оценка за ДЗ', data: scores }],
|
series: [{ name: 'Оценка за ДЗ', data: scores }],
|
||||||
categories,
|
categories,
|
||||||
};
|
};
|
||||||
}, [homeworkSubmissions, startStr, endStr]);
|
}, [homeworkSubmissions, startStr, endStr]);
|
||||||
|
|
||||||
// Посещаемость: по датам — сколько занятий было проведено; ось X = все даты периода
|
// Посещаемость: по датам — сколько занятий было проведено; ось X = все даты периода
|
||||||
const attendanceChart = useMemo(() => {
|
const attendanceChart = useMemo(() => {
|
||||||
const allDates = getDatesInRange(startStr, endStr);
|
const allDates = getDatesInRange(startStr, endStr);
|
||||||
const categories = allDates.map((d) => {
|
const categories = allDates.map((d) => {
|
||||||
const [, m, day] = d.split('-');
|
const [, m, day] = d.split('-');
|
||||||
return `${day}.${m}`;
|
return `${day}.${m}`;
|
||||||
});
|
});
|
||||||
const byDate: Record<string, number> = {};
|
const byDate: Record<string, number> = {};
|
||||||
lessons.forEach((l) => {
|
lessons.forEach((l) => {
|
||||||
if (l.status !== 'completed') return;
|
if (l.status !== 'completed') return;
|
||||||
const key = l.start_time?.slice(0, 10) || '';
|
const key = l.start_time?.slice(0, 10) || '';
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
byDate[key] = (byDate[key] ?? 0) + 1;
|
byDate[key] = (byDate[key] ?? 0) + 1;
|
||||||
});
|
});
|
||||||
const data = allDates.map((d) => byDate[d] ?? 0);
|
const data = allDates.map((d) => byDate[d] ?? 0);
|
||||||
return {
|
return {
|
||||||
series: [{ name: 'Занятия проведены', data }],
|
series: [{ name: 'Занятия проведены', data }],
|
||||||
categories,
|
categories,
|
||||||
};
|
};
|
||||||
}, [lessons, startStr, endStr]);
|
}, [lessons, startStr, endStr]);
|
||||||
|
|
||||||
const subjects = subjectsFromLessons;
|
const subjects = subjectsFromLessons;
|
||||||
const loading = lessonsLoading && lessons.length === 0;
|
const loading = lessonsLoading && lessons.length === 0;
|
||||||
|
|
||||||
const chartOptionsBase = useMemo(
|
const chartOptionsBase = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
chart: {
|
chart: {
|
||||||
toolbar: {
|
toolbar: {
|
||||||
show: true,
|
show: true,
|
||||||
tools: {
|
tools: {
|
||||||
download: true,
|
download: true,
|
||||||
zoom: true,
|
zoom: true,
|
||||||
zoomin: true,
|
zoomin: true,
|
||||||
zoomout: true,
|
zoomout: true,
|
||||||
pan: true,
|
pan: true,
|
||||||
reset: true,
|
reset: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
zoom: { enabled: true, type: 'x' as const, allowMouseWheelZoom: true },
|
zoom: { enabled: true, type: 'x' as const, allowMouseWheelZoom: true },
|
||||||
pan: { enabled: true, type: 'x' as const },
|
pan: { enabled: true, type: 'x' as const },
|
||||||
selection: { enabled: true, type: 'x' as const },
|
selection: { enabled: true, type: 'x' as const },
|
||||||
},
|
},
|
||||||
stroke: { curve: 'smooth' as const, width: 2 },
|
stroke: { curve: 'smooth' as const, width: 2 },
|
||||||
colors: CHART_COLORS,
|
colors: CHART_COLORS,
|
||||||
dataLabels: { enabled: false },
|
dataLabels: { enabled: false },
|
||||||
xaxis: {
|
xaxis: {
|
||||||
axisBorder: { show: false },
|
axisBorder: { show: false },
|
||||||
axisTicks: { show: false },
|
axisTicks: { show: false },
|
||||||
labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } },
|
labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } },
|
||||||
},
|
},
|
||||||
yaxis: {
|
yaxis: {
|
||||||
labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } },
|
labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } },
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom' as const,
|
position: 'bottom' as const,
|
||||||
horizontalAlign: 'center' as const,
|
horizontalAlign: 'center' as const,
|
||||||
labels: { colors: 'var(--md-sys-color-on-surface-variant)' },
|
labels: { colors: 'var(--md-sys-color-on-surface-variant)' },
|
||||||
},
|
},
|
||||||
grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 },
|
grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 },
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const selectStyle = {
|
const selectStyle = {
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
|
border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
|
||||||
background: 'var(--md-sys-color-surface-container-low)',
|
background: 'var(--md-sys-color-surface-container-low)',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
minWidth: 180,
|
minWidth: 180,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
cursor: 'pointer' as const,
|
cursor: 'pointer' as const,
|
||||||
outline: 'none' as const,
|
outline: 'none' as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: '100%', minHeight: '100vh' }}>
|
<div style={{ width: '100%' }}>
|
||||||
<DashboardLayout className="ios26-dashboard-grid">
|
<DashboardLayout className="ios26-dashboard-grid">
|
||||||
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
|
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
|
||||||
<Panel padding="md">
|
<Panel padding="md">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Прогресс за период"
|
title="Прогресс за период"
|
||||||
trailing={
|
trailing={
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center' }}>
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center' }}>
|
||||||
<select
|
<select
|
||||||
value={selectedSubject}
|
value={selectedSubject}
|
||||||
onChange={(e) => setSelectedSubject(e.target.value)}
|
onChange={(e) => setSelectedSubject(e.target.value)}
|
||||||
disabled={!subjects.length}
|
disabled={!subjects.length}
|
||||||
style={{
|
style={{
|
||||||
...selectStyle,
|
...selectStyle,
|
||||||
opacity: subjects.length ? 1 : 0.7,
|
opacity: subjects.length ? 1 : 0.7,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{subjects.length === 0 ? (
|
{subjects.length === 0 ? (
|
||||||
<option value="">Нет предметов</option>
|
<option value="">Нет предметов</option>
|
||||||
) : (
|
) : (
|
||||||
subjects.map((s) => (
|
subjects.map((s) => (
|
||||||
<option key={s} value={s}>{s}</option>
|
<option key={s} value={s}>{s}</option>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
<DateRangePicker
|
<DateRangePicker
|
||||||
value={dateRangeValue}
|
value={dateRangeValue}
|
||||||
onChange={(v) => setDateRangeValue({ start_date: v.start_date, end_date: v.end_date })}
|
onChange={(v) => setDateRangeValue({ start_date: v.start_date, end_date: v.end_date })}
|
||||||
disabled={subjectsLoading}
|
disabled={subjectsLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<LoadingSpinner size="medium" />
|
<LoadingSpinner size="medium" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="ios26-stat-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
|
<div className="ios26-stat-grid my-progress-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
|
||||||
<div className="ios26-stat-tile">
|
<div className="ios26-stat-tile">
|
||||||
<div className="ios26-stat-label">Занятий проведено</div>
|
<div className="ios26-stat-label">Занятий проведено</div>
|
||||||
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.completedLessons}</div>
|
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.completedLessons}</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>из {periodStats.totalLessons}</div>
|
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)' }}>из {periodStats.totalLessons}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ios26-stat-tile">
|
<div className="ios26-stat-tile">
|
||||||
<div className="ios26-stat-label">Посещаемость</div>
|
<div className="ios26-stat-label">Посещаемость</div>
|
||||||
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.attendanceRate}%</div>
|
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.attendanceRate}%</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ios26-stat-tile">
|
<div className="ios26-stat-tile">
|
||||||
<div className="ios26-stat-label">Средняя оценка</div>
|
<div className="ios26-stat-label">Средняя оценка</div>
|
||||||
<div className="ios26-stat-value">{periodStats.avgGrade || '—'}</div>
|
<div className="ios26-stat-value">{periodStats.avgGrade || '—'}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="ios26-stat-tile">
|
<div className="ios26-stat-tile">
|
||||||
<div className="ios26-stat-label">ДЗ с оценкой</div>
|
<div className="ios26-stat-label">ДЗ с оценкой</div>
|
||||||
<div className="ios26-stat-value">{periodStats.hwGraded}</div>
|
<div className="ios26-stat-value">{periodStats.hwGraded}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Ячейка 2: Успеваемость (оценки репетитора и школы) */}
|
{/* Ячейка 2: Успеваемость (оценки репетитора и школы) */}
|
||||||
<Panel padding="md">
|
<Panel padding="md">
|
||||||
<SectionHeader title="Успеваемость (репетитор и школа)" />
|
<SectionHeader title="Успеваемость (репетитор и школа)" />
|
||||||
{gradesChart.categories.length === 0 ? (
|
{gradesChart.categories.length === 0 ? (
|
||||||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||||||
Нет оценок за период
|
Нет оценок за период
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Chart
|
<Chart
|
||||||
type="line"
|
type="line"
|
||||||
height={260}
|
height={260}
|
||||||
series={gradesChart.series}
|
series={gradesChart.series}
|
||||||
options={{
|
options={{
|
||||||
...chartOptionsBase,
|
...chartOptionsBase,
|
||||||
xaxis: { ...chartOptionsBase.xaxis, categories: gradesChart.categories },
|
xaxis: { ...chartOptionsBase.xaxis, categories: gradesChart.categories },
|
||||||
yaxis: { ...chartOptionsBase.yaxis, min: 1, max: 5, title: { text: 'Оценка (1–5)' } },
|
yaxis: { ...chartOptionsBase.yaxis, min: 1, max: 5, title: { text: 'Оценка (1–5)' } },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Ячейка 3: Успеваемость по ДЗ */}
|
{/* Ячейка 3: Успеваемость по ДЗ */}
|
||||||
<Panel padding="md">
|
<Panel padding="md">
|
||||||
<SectionHeader title="Успеваемость по ДЗ" />
|
<SectionHeader title="Успеваемость по ДЗ" />
|
||||||
{homeworkChart.categories.length === 0 ? (
|
{homeworkChart.categories.length === 0 ? (
|
||||||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||||||
Нет оценок за ДЗ за период
|
Нет оценок за ДЗ за период
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Chart
|
<Chart
|
||||||
type="line"
|
type="line"
|
||||||
height={260}
|
height={260}
|
||||||
series={homeworkChart.series}
|
series={homeworkChart.series}
|
||||||
options={{
|
options={{
|
||||||
...chartOptionsBase,
|
...chartOptionsBase,
|
||||||
colors: ['#6750A4'],
|
colors: ['#6750A4'],
|
||||||
xaxis: { ...chartOptionsBase.xaxis, categories: homeworkChart.categories },
|
xaxis: { ...chartOptionsBase.xaxis, categories: homeworkChart.categories },
|
||||||
yaxis: { ...chartOptionsBase.yaxis, min: 0, max: 100, title: { text: 'Оценка' } },
|
yaxis: { ...chartOptionsBase.yaxis, min: 0, max: 100, title: { text: 'Оценка' } },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
{/* Ячейка 4: Посещаемость — проведённые занятия по датам */}
|
{/* Ячейка 4: Посещаемость — проведённые занятия по датам */}
|
||||||
<Panel padding="md">
|
<Panel padding="md">
|
||||||
<SectionHeader title="Посещаемость" />
|
<SectionHeader title="Посещаемость" />
|
||||||
{attendanceChart.categories.length === 0 ? (
|
{attendanceChart.categories.length === 0 ? (
|
||||||
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||||||
Нет проведённых занятий за период
|
Нет проведённых занятий за период
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Chart
|
<Chart
|
||||||
type="line"
|
type="line"
|
||||||
height={260}
|
height={260}
|
||||||
series={attendanceChart.series}
|
series={attendanceChart.series}
|
||||||
options={{
|
options={{
|
||||||
...chartOptionsBase,
|
...chartOptionsBase,
|
||||||
colors: ['#6750A4'],
|
colors: ['#6750A4'],
|
||||||
xaxis: { ...chartOptionsBase.xaxis, categories: attendanceChart.categories },
|
xaxis: { ...chartOptionsBase.xaxis, categories: attendanceChart.categories },
|
||||||
yaxis: {
|
yaxis: {
|
||||||
...chartOptionsBase.yaxis,
|
...chartOptionsBase.yaxis,
|
||||||
title: { text: 'Занятий' },
|
title: { text: 'Занятий' },
|
||||||
min: 0,
|
min: 0,
|
||||||
tickAmount: 4,
|
tickAmount: 4,
|
||||||
labels: {
|
labels: {
|
||||||
...(chartOptionsBase.yaxis?.labels ?? {}),
|
...(chartOptionsBase.yaxis?.labels ?? {}),
|
||||||
formatter: (val: number) => String(Math.round(val)),
|
formatter: (val: number) => String(Math.round(val)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,26 +1,27 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ReferralsPageContent } from '@/components/referrals/ReferralsPageContent';
|
import { ReferralsPageContent } from '@/components/referrals/ReferralsPageContent';
|
||||||
|
|
||||||
export default function ReferralsPage() {
|
export default function ReferralsPage() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="page-referrals"
|
||||||
minHeight: '100vh',
|
style={{
|
||||||
padding: 24,
|
padding: 24,
|
||||||
background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)',
|
background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
className="page-referrals-card"
|
||||||
background: '#fff',
|
style={{
|
||||||
borderRadius: 20,
|
background: '#fff',
|
||||||
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
|
borderRadius: 20,
|
||||||
padding: 24,
|
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
|
||||||
}}
|
padding: 24,
|
||||||
>
|
}}
|
||||||
<ReferralsPageContent />
|
>
|
||||||
</div>
|
<ReferralsPageContent />
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,464 +1,464 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
startOfDay,
|
startOfDay,
|
||||||
format,
|
format,
|
||||||
addDays,
|
addDays,
|
||||||
subDays,
|
subDays,
|
||||||
subMonths,
|
subMonths,
|
||||||
addMonths,
|
addMonths,
|
||||||
startOfMonth,
|
startOfMonth,
|
||||||
endOfMonth,
|
endOfMonth,
|
||||||
differenceInMinutes,
|
differenceInMinutes,
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { ru } from 'date-fns/locale';
|
import { ru } from 'date-fns/locale';
|
||||||
import { Calendar } from '@/components/calendar/calendar';
|
import { Calendar } from '@/components/calendar/calendar';
|
||||||
import { CheckLesson } from '@/components/checklesson/checklesson';
|
import { CheckLesson } from '@/components/checklesson/checklesson';
|
||||||
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
|
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
|
||||||
import { getStudents } from '@/api/students';
|
import { getStudents } from '@/api/students';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
import { useSelectedChild } from '@/contexts/SelectedChildContext';
|
||||||
import { getSubjects, getMentorSubjects } from '@/api/subjects';
|
import { getSubjects, getMentorSubjects } from '@/api/subjects';
|
||||||
import { loadComponent } from '@/lib/material-components';
|
import { loadComponent } from '@/lib/material-components';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { ErrorDisplay } from '@/components/common/ErrorDisplay';
|
import { ErrorDisplay } from '@/components/common/ErrorDisplay';
|
||||||
import type { CalendarLesson } from '@/components/calendar/calendar';
|
import type { CalendarLesson } from '@/components/calendar/calendar';
|
||||||
import type { CheckLessonFormData, CheckLessonProps } from '@/components/checklesson/checklesson';
|
import type { CheckLessonFormData, CheckLessonProps } from '@/components/checklesson/checklesson';
|
||||||
import type { LessonPreview } from '@/api/dashboard';
|
import type { LessonPreview } from '@/api/dashboard';
|
||||||
import type { Student } from '@/api/students';
|
import type { Student } from '@/api/students';
|
||||||
import type { Subject, MentorSubject } from '@/api/subjects';
|
import type { Subject, MentorSubject } from '@/api/subjects';
|
||||||
|
|
||||||
export default function SchedulePage() {
|
export default function SchedulePage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { selectedChild } = useSelectedChild();
|
const { selectedChild } = useSelectedChild();
|
||||||
const isMentor = user?.role === 'mentor';
|
const isMentor = user?.role === 'mentor';
|
||||||
const [selectedDate, setSelectedDate] = useState<Date>(startOfDay(new Date()));
|
const [selectedDate, setSelectedDate] = useState<Date>(startOfDay(new Date()));
|
||||||
const [displayDate, setDisplayDate] = useState<Date>(startOfDay(new Date()));
|
const [displayDate, setDisplayDate] = useState<Date>(startOfDay(new Date()));
|
||||||
const [visibleMonth, setVisibleMonth] = useState<Date>(() => startOfMonth(new Date()));
|
const [visibleMonth, setVisibleMonth] = useState<Date>(() => startOfMonth(new Date()));
|
||||||
const [lessons, setLessons] = useState<CalendarLesson[]>([]);
|
const [lessons, setLessons] = useState<CalendarLesson[]>([]);
|
||||||
const [lessonsLoading, setLessonsLoading] = useState(true);
|
const [lessonsLoading, setLessonsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const formDataLoadedRef = useRef(false);
|
const formDataLoadedRef = useRef(false);
|
||||||
const hasLoadedLessonsOnceRef = useRef(false);
|
const hasLoadedLessonsOnceRef = useRef(false);
|
||||||
|
|
||||||
// Форма
|
// Форма
|
||||||
const [isFormVisible, setIsFormVisible] = useState(false);
|
const [isFormVisible, setIsFormVisible] = useState(false);
|
||||||
const [isEditingMode, setIsEditingMode] = useState(false);
|
const [isEditingMode, setIsEditingMode] = useState(false);
|
||||||
const [formLoading, setFormLoading] = useState(false);
|
const [formLoading, setFormLoading] = useState(false);
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
const [formData, setFormData] = useState<CheckLessonFormData>({
|
const [formData, setFormData] = useState<CheckLessonFormData>({
|
||||||
client: '',
|
client: '',
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
start_date: format(selectedDate, 'yyyy-MM-dd'),
|
start_date: format(selectedDate, 'yyyy-MM-dd'),
|
||||||
start_time: '14:00',
|
start_time: '14:00',
|
||||||
duration: 60,
|
duration: 60,
|
||||||
price: undefined,
|
price: undefined,
|
||||||
is_recurring: false,
|
is_recurring: false,
|
||||||
});
|
});
|
||||||
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
|
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
|
||||||
const [selectedMentorSubjectId, setSelectedMentorSubjectId] = useState<number | null>(null);
|
const [selectedMentorSubjectId, setSelectedMentorSubjectId] = useState<number | null>(null);
|
||||||
const [editingLessonId, setEditingLessonId] = useState<string | null>(null);
|
const [editingLessonId, setEditingLessonId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Компоненты Material Web
|
// Компоненты Material Web
|
||||||
const [buttonComponentsLoaded, setButtonComponentsLoaded] = useState(false);
|
const [buttonComponentsLoaded, setButtonComponentsLoaded] = useState(false);
|
||||||
const [formComponentsLoaded, setFormComponentsLoaded] = useState(false);
|
const [formComponentsLoaded, setFormComponentsLoaded] = useState(false);
|
||||||
|
|
||||||
// Данные для формы
|
// Данные для формы
|
||||||
const [students, setStudents] = useState<Student[]>([]);
|
const [students, setStudents] = useState<Student[]>([]);
|
||||||
const [subjects, setSubjects] = useState<Subject[]>([]);
|
const [subjects, setSubjects] = useState<Subject[]>([]);
|
||||||
const [mentorSubjects, setMentorSubjects] = useState<MentorSubject[]>([]);
|
const [mentorSubjects, setMentorSubjects] = useState<MentorSubject[]>([]);
|
||||||
const [lessonEditLoading, setLessonEditLoading] = useState(false);
|
const [lessonEditLoading, setLessonEditLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadComponent('elevated-card'),
|
loadComponent('elevated-card'),
|
||||||
loadComponent('filled-button'),
|
loadComponent('filled-button'),
|
||||||
loadComponent('icon'),
|
loadComponent('icon'),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
setButtonComponentsLoaded(true);
|
setButtonComponentsLoaded(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
Promise.all([
|
Promise.all([
|
||||||
loadComponent('filled-button'),
|
loadComponent('filled-button'),
|
||||||
loadComponent('outlined-button'),
|
loadComponent('outlined-button'),
|
||||||
loadComponent('text-field'),
|
loadComponent('text-field'),
|
||||||
loadComponent('select'),
|
loadComponent('select'),
|
||||||
loadComponent('switch'),
|
loadComponent('switch'),
|
||||||
]).then(() => {
|
]).then(() => {
|
||||||
setFormComponentsLoaded(true);
|
setFormComponentsLoaded(true);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isFormVisible || formDataLoadedRef.current) return;
|
if (!isFormVisible || formDataLoadedRef.current) return;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const [studentsResp, subjectsResp, mentorSubjectsResp] = await Promise.all([
|
const [studentsResp, subjectsResp, mentorSubjectsResp] = await Promise.all([
|
||||||
getStudents({ page: 1, page_size: 200 }),
|
getStudents({ page: 1, page_size: 200 }),
|
||||||
getSubjects(),
|
getSubjects(),
|
||||||
getMentorSubjects(),
|
getMentorSubjects(),
|
||||||
]);
|
]);
|
||||||
setStudents(studentsResp.results || []);
|
setStudents(studentsResp.results || []);
|
||||||
setSubjects(subjectsResp || []);
|
setSubjects(subjectsResp || []);
|
||||||
setMentorSubjects(mentorSubjectsResp || []);
|
setMentorSubjects(mentorSubjectsResp || []);
|
||||||
formDataLoadedRef.current = true;
|
formDataLoadedRef.current = true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading form data:', err);
|
console.error('Error loading form data:', err);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [isFormVisible]);
|
}, [isFormVisible]);
|
||||||
|
|
||||||
const loadLessons = useCallback(async () => {
|
const loadLessons = useCallback(async () => {
|
||||||
const start = startOfMonth(subMonths(visibleMonth, 1));
|
const start = startOfMonth(subMonths(visibleMonth, 1));
|
||||||
const end = endOfMonth(addMonths(visibleMonth, 1));
|
const end = endOfMonth(addMonths(visibleMonth, 1));
|
||||||
const isInitial = !hasLoadedLessonsOnceRef.current;
|
const isInitial = !hasLoadedLessonsOnceRef.current;
|
||||||
try {
|
try {
|
||||||
if (isInitial) setLessonsLoading(true);
|
if (isInitial) setLessonsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const { lessons: lessonsData } = await getLessonsCalendar({
|
const { lessons: lessonsData } = await getLessonsCalendar({
|
||||||
start_date: format(start, 'yyyy-MM-dd'),
|
start_date: format(start, 'yyyy-MM-dd'),
|
||||||
end_date: format(end, 'yyyy-MM-dd'),
|
end_date: format(end, 'yyyy-MM-dd'),
|
||||||
...(selectedChild?.id && { child_id: selectedChild.id }),
|
...(selectedChild?.id && { child_id: selectedChild.id }),
|
||||||
});
|
});
|
||||||
const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({
|
const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({
|
||||||
id: lesson.id,
|
id: lesson.id,
|
||||||
title: lesson.title,
|
title: lesson.title,
|
||||||
start_time: lesson.start_time,
|
start_time: lesson.start_time,
|
||||||
end_time: lesson.end_time,
|
end_time: lesson.end_time,
|
||||||
status: lesson.status,
|
status: lesson.status,
|
||||||
client: lesson.client?.id,
|
client: lesson.client?.id,
|
||||||
client_name: lesson.client_name ?? (lesson.client?.user
|
client_name: lesson.client_name ?? (lesson.client?.user
|
||||||
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
|
||||||
: undefined),
|
: undefined),
|
||||||
subject: lesson.subject ?? lesson.subject_name ?? '',
|
subject: lesson.subject ?? lesson.subject_name ?? '',
|
||||||
}));
|
}));
|
||||||
setLessons(mappedLessons);
|
setLessons(mappedLessons);
|
||||||
hasLoadedLessonsOnceRef.current = true;
|
hasLoadedLessonsOnceRef.current = true;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error loading lessons:', err);
|
console.error('Error loading lessons:', err);
|
||||||
setError(err?.message || 'Ошибка загрузки занятий');
|
setError(err?.message || 'Ошибка загрузки занятий');
|
||||||
} finally {
|
} finally {
|
||||||
if (isInitial) setLessonsLoading(false);
|
if (isInitial) setLessonsLoading(false);
|
||||||
}
|
}
|
||||||
}, [visibleMonth, selectedChild?.id]);
|
}, [visibleMonth, selectedChild?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLessons();
|
loadLessons();
|
||||||
}, [loadLessons]);
|
}, [loadLessons]);
|
||||||
|
|
||||||
const handleMonthChange = useCallback((start: Date, _end: Date) => {
|
const handleMonthChange = useCallback((start: Date, _end: Date) => {
|
||||||
const key = format(start, 'yyyy-MM');
|
const key = format(start, 'yyyy-MM');
|
||||||
if (key === format(visibleMonth, 'yyyy-MM')) return;
|
if (key === format(visibleMonth, 'yyyy-MM')) return;
|
||||||
setVisibleMonth(startOfMonth(start));
|
setVisibleMonth(startOfMonth(start));
|
||||||
}, [visibleMonth]);
|
}, [visibleMonth]);
|
||||||
|
|
||||||
const lessonsForSelectedDate: LessonPreview[] = lessons
|
const lessonsForSelectedDate: LessonPreview[] = lessons
|
||||||
.filter((lesson) => {
|
.filter((lesson) => {
|
||||||
const lessonDate = startOfDay(new Date(lesson.start_time));
|
const lessonDate = startOfDay(new Date(lesson.start_time));
|
||||||
return lessonDate.getTime() === selectedDate.getTime();
|
return lessonDate.getTime() === selectedDate.getTime();
|
||||||
})
|
})
|
||||||
.map((lesson) => ({
|
.map((lesson) => ({
|
||||||
id: String(lesson.id),
|
id: String(lesson.id),
|
||||||
title: lesson.title || 'Занятие',
|
title: lesson.title || 'Занятие',
|
||||||
subject: lesson.subject ?? '',
|
subject: lesson.subject ?? '',
|
||||||
start_time: lesson.start_time,
|
start_time: lesson.start_time,
|
||||||
end_time: lesson.end_time,
|
end_time: lesson.end_time,
|
||||||
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
|
||||||
client: lesson.client_name
|
client: lesson.client_name
|
||||||
? {
|
? {
|
||||||
id: String(lesson.client || ''),
|
id: String(lesson.client || ''),
|
||||||
name: lesson.client_name,
|
name: lesson.client_name,
|
||||||
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
|
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
|
||||||
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
|
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleSelectSlot = (date: Date) => {
|
const handleSelectSlot = (date: Date) => {
|
||||||
const dayStart = startOfDay(date);
|
const dayStart = startOfDay(date);
|
||||||
setSelectedDate(dayStart);
|
setSelectedDate(dayStart);
|
||||||
setDisplayDate(dayStart);
|
setDisplayDate(dayStart);
|
||||||
setIsFormVisible(false);
|
setIsFormVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectEvent = (lesson: { id: string }) => {
|
const handleSelectEvent = (lesson: { id: string }) => {
|
||||||
if (isMentor) handleLessonClick(lesson);
|
if (isMentor) handleLessonClick(lesson);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrevDay = () => {
|
const handlePrevDay = () => {
|
||||||
const prev = subDays(displayDate, 1);
|
const prev = subDays(displayDate, 1);
|
||||||
setDisplayDate(prev);
|
setDisplayDate(prev);
|
||||||
setSelectedDate(startOfDay(prev));
|
setSelectedDate(startOfDay(prev));
|
||||||
setIsFormVisible(false);
|
setIsFormVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNextDay = () => {
|
const handleNextDay = () => {
|
||||||
const next = addDays(displayDate, 1);
|
const next = addDays(displayDate, 1);
|
||||||
setDisplayDate(next);
|
setDisplayDate(next);
|
||||||
setSelectedDate(startOfDay(next));
|
setSelectedDate(startOfDay(next));
|
||||||
setIsFormVisible(false);
|
setIsFormVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddLesson = () => {
|
const handleAddLesson = () => {
|
||||||
setIsEditingMode(false);
|
setIsEditingMode(false);
|
||||||
setFormData({
|
setFormData({
|
||||||
client: '',
|
client: '',
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
start_date: format(selectedDate, 'yyyy-MM-dd'),
|
start_date: format(selectedDate, 'yyyy-MM-dd'),
|
||||||
start_time: '14:00',
|
start_time: '14:00',
|
||||||
duration: 60,
|
duration: 60,
|
||||||
price: undefined,
|
price: undefined,
|
||||||
is_recurring: false,
|
is_recurring: false,
|
||||||
});
|
});
|
||||||
setSelectedSubjectId(null);
|
setSelectedSubjectId(null);
|
||||||
setSelectedMentorSubjectId(null);
|
setSelectedMentorSubjectId(null);
|
||||||
setIsFormVisible(true);
|
setIsFormVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLessonClick = (lesson: { id: string }) => {
|
const handleLessonClick = (lesson: { id: string }) => {
|
||||||
if (!isMentor) return; // Добавить/редактировать/просмотр — только для ментора
|
if (!isMentor) return; // Добавить/редактировать/просмотр — только для ментора
|
||||||
setIsEditingMode(true);
|
setIsEditingMode(true);
|
||||||
setIsFormVisible(true);
|
setIsFormVisible(true);
|
||||||
setLessonEditLoading(true);
|
setLessonEditLoading(true);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditingLessonId(lesson.id);
|
setEditingLessonId(lesson.id);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const details = await getLesson(String(lesson.id));
|
const details = await getLesson(String(lesson.id));
|
||||||
const start = new Date(details.start_time);
|
const start = new Date(details.start_time);
|
||||||
const end = new Date(details.end_time);
|
const end = new Date(details.end_time);
|
||||||
const safeStart = startOfDay(start);
|
const safeStart = startOfDay(start);
|
||||||
|
|
||||||
// синхронизируем правую панель с датой урока
|
// синхронизируем правую панель с датой урока
|
||||||
setSelectedDate(safeStart);
|
setSelectedDate(safeStart);
|
||||||
setDisplayDate(safeStart);
|
setDisplayDate(safeStart);
|
||||||
|
|
||||||
const duration = (() => {
|
const duration = (() => {
|
||||||
const mins = differenceInMinutes(end, start);
|
const mins = differenceInMinutes(end, start);
|
||||||
return Number.isFinite(mins) && mins > 0 ? mins : 60;
|
return Number.isFinite(mins) && mins > 0 ? mins : 60;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
client: details.client?.id ? String(details.client.id) : '',
|
client: details.client?.id ? String(details.client.id) : '',
|
||||||
title: details.title ?? '',
|
title: details.title ?? '',
|
||||||
description: details.description ?? '',
|
description: details.description ?? '',
|
||||||
start_date: format(start, 'yyyy-MM-dd'),
|
start_date: format(start, 'yyyy-MM-dd'),
|
||||||
start_time: format(start, 'HH:mm'),
|
start_time: format(start, 'HH:mm'),
|
||||||
duration,
|
duration,
|
||||||
price: typeof details.price === 'number' ? details.price : undefined,
|
price: typeof details.price === 'number' ? details.price : undefined,
|
||||||
is_recurring: !!(details as any).is_recurring,
|
is_recurring: !!(details as any).is_recurring,
|
||||||
});
|
});
|
||||||
|
|
||||||
// пробуем выставить предмет по названию
|
// пробуем выставить предмет по названию
|
||||||
const subjName = (details as any).subject_name || (details as any).subject || '';
|
const subjName = (details as any).subject_name || (details as any).subject || '';
|
||||||
if (subjName) {
|
if (subjName) {
|
||||||
const foundSubject = subjects.find((s) => s.name === subjName);
|
const foundSubject = subjects.find((s) => s.name === subjName);
|
||||||
const foundMentorSubject = mentorSubjects.find((s) => s.name === subjName);
|
const foundMentorSubject = mentorSubjects.find((s) => s.name === subjName);
|
||||||
if (foundMentorSubject) {
|
if (foundMentorSubject) {
|
||||||
setSelectedSubjectId(null);
|
setSelectedSubjectId(null);
|
||||||
setSelectedMentorSubjectId(foundMentorSubject.id);
|
setSelectedMentorSubjectId(foundMentorSubject.id);
|
||||||
} else if (foundSubject) {
|
} else if (foundSubject) {
|
||||||
setSelectedSubjectId(foundSubject.id);
|
setSelectedSubjectId(foundSubject.id);
|
||||||
setSelectedMentorSubjectId(null);
|
setSelectedMentorSubjectId(null);
|
||||||
} else {
|
} else {
|
||||||
setSelectedSubjectId(null);
|
setSelectedSubjectId(null);
|
||||||
setSelectedMentorSubjectId(null);
|
setSelectedMentorSubjectId(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSelectedSubjectId(null);
|
setSelectedSubjectId(null);
|
||||||
setSelectedMentorSubjectId(null);
|
setSelectedMentorSubjectId(null);
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error loading lesson:', err);
|
console.error('Error loading lesson:', err);
|
||||||
setFormError(err?.message || 'Не удалось загрузить данные занятия');
|
setFormError(err?.message || 'Не удалось загрузить данные занятия');
|
||||||
} finally {
|
} finally {
|
||||||
setLessonEditLoading(false);
|
setLessonEditLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubjectChange = (subjectId: number | null, mentorSubjectId: number | null) => {
|
const handleSubjectChange = (subjectId: number | null, mentorSubjectId: number | null) => {
|
||||||
setSelectedSubjectId(subjectId);
|
setSelectedSubjectId(subjectId);
|
||||||
setSelectedMentorSubjectId(mentorSubjectId);
|
setSelectedMentorSubjectId(mentorSubjectId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSubjectName = () => {
|
const getSubjectName = () => {
|
||||||
if (selectedSubjectId) {
|
if (selectedSubjectId) {
|
||||||
const s = subjects.find((x) => x.id === selectedSubjectId);
|
const s = subjects.find((x) => x.id === selectedSubjectId);
|
||||||
return s?.name ?? '';
|
return s?.name ?? '';
|
||||||
}
|
}
|
||||||
if (selectedMentorSubjectId) {
|
if (selectedMentorSubjectId) {
|
||||||
const s = mentorSubjects.find((x) => x.id === selectedMentorSubjectId);
|
const s = mentorSubjects.find((x) => x.id === selectedMentorSubjectId);
|
||||||
return s?.name ?? '';
|
return s?.name ?? '';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
|
|
||||||
const generateTitle = () => {
|
const generateTitle = () => {
|
||||||
const student = students.find((s) => String(s.id) === formData.client);
|
const student = students.find((s) => String(s.id) === formData.client);
|
||||||
const studentName = student
|
const studentName = student
|
||||||
? `${student.user?.first_name || ''} ${student.user?.last_name || ''}`.trim() || student.user?.email
|
? `${student.user?.first_name || ''} ${student.user?.last_name || ''}`.trim() || student.user?.email
|
||||||
: '';
|
: '';
|
||||||
const subjectName = getSubjectName();
|
const subjectName = getSubjectName();
|
||||||
if (studentName && subjectName) return `${subjectName} — ${studentName}`;
|
if (studentName && subjectName) return `${subjectName} — ${studentName}`;
|
||||||
if (studentName) return studentName;
|
if (studentName) return studentName;
|
||||||
if (subjectName) return subjectName;
|
if (subjectName) return subjectName;
|
||||||
return formData.title || 'Занятие';
|
return formData.title || 'Занятие';
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormLoading(true);
|
setFormLoading(true);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!formData.client) {
|
if (!formData.client) {
|
||||||
setFormError('Выберите ученика');
|
setFormError('Выберите ученика');
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedSubjectId && !selectedMentorSubjectId) {
|
if (!selectedSubjectId && !selectedMentorSubjectId) {
|
||||||
setFormError('Выберите предмет');
|
setFormError('Выберите предмет');
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!formData.start_date || !formData.start_time) {
|
if (!formData.start_date || !formData.start_time) {
|
||||||
setFormError('Укажите дату и время');
|
setFormError('Укажите дату и время');
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (formData.price == null || formData.price < 0) {
|
if (formData.price == null || formData.price < 0) {
|
||||||
setFormError('Укажите стоимость занятия');
|
setFormError('Укажите стоимость занятия');
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString();
|
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString();
|
||||||
const title = generateTitle();
|
const title = generateTitle();
|
||||||
|
|
||||||
if (isEditingMode && editingLessonId) {
|
if (isEditingMode && editingLessonId) {
|
||||||
await updateLesson(editingLessonId, {
|
await updateLesson(editingLessonId, {
|
||||||
title,
|
title,
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
start_time: startUtc,
|
start_time: startUtc,
|
||||||
duration: formData.duration,
|
duration: formData.duration,
|
||||||
price: formData.price,
|
price: formData.price,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
client: formData.client,
|
client: formData.client,
|
||||||
title,
|
title,
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
start_time: startUtc,
|
start_time: startUtc,
|
||||||
duration: formData.duration,
|
duration: formData.duration,
|
||||||
price: formData.price,
|
price: formData.price,
|
||||||
is_recurring: formData.is_recurring,
|
is_recurring: formData.is_recurring,
|
||||||
};
|
};
|
||||||
if (selectedSubjectId) payload.subject_id = selectedSubjectId;
|
if (selectedSubjectId) payload.subject_id = selectedSubjectId;
|
||||||
else if (selectedMentorSubjectId) payload.mentor_subject_id = selectedMentorSubjectId;
|
else if (selectedMentorSubjectId) payload.mentor_subject_id = selectedMentorSubjectId;
|
||||||
|
|
||||||
await createLesson(payload);
|
await createLesson(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsFormVisible(false);
|
setIsFormVisible(false);
|
||||||
setEditingLessonId(null);
|
setEditingLessonId(null);
|
||||||
loadLessons();
|
loadLessons();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const msg = err?.response?.data
|
const msg = err?.response?.data
|
||||||
? typeof err.response.data === 'object'
|
? typeof err.response.data === 'object'
|
||||||
? Object.entries(err.response.data)
|
? Object.entries(err.response.data)
|
||||||
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
|
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
|
||||||
.join('\n')
|
.join('\n')
|
||||||
: String(err.response.data)
|
: String(err.response.data)
|
||||||
: err?.message || 'Ошибка сохранения занятия';
|
: err?.message || 'Ошибка сохранения занятия';
|
||||||
setFormError(msg);
|
setFormError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (deleteAllFuture: boolean) => {
|
const handleDelete = async (deleteAllFuture: boolean) => {
|
||||||
if (!editingLessonId) return;
|
if (!editingLessonId) return;
|
||||||
setFormLoading(true);
|
setFormLoading(true);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
try {
|
try {
|
||||||
await deleteLesson(editingLessonId, deleteAllFuture);
|
await deleteLesson(editingLessonId, deleteAllFuture);
|
||||||
setIsFormVisible(false);
|
setIsFormVisible(false);
|
||||||
setEditingLessonId(null);
|
setEditingLessonId(null);
|
||||||
loadLessons();
|
loadLessons();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setFormError(err?.message || 'Ошибка удаления занятия');
|
setFormError(err?.message || 'Ошибка удаления занятия');
|
||||||
} finally {
|
} finally {
|
||||||
setFormLoading(false);
|
setFormLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
const handleCancel = () => {
|
||||||
setIsFormVisible(false);
|
setIsFormVisible(false);
|
||||||
setIsEditingMode(false);
|
setIsEditingMode(false);
|
||||||
setFormError(null);
|
setFormError(null);
|
||||||
setEditingLessonId(null);
|
setEditingLessonId(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="ios26-dashboard ios26-schedule-page" style={{ padding: '16px' }}>
|
<div className="ios26-dashboard ios26-schedule-page" style={{ padding: '16px' }}>
|
||||||
{error && <ErrorDisplay error={error} onRetry={loadLessons} />}
|
{error && <ErrorDisplay error={error} onRetry={loadLessons} />}
|
||||||
|
|
||||||
<div className="ios26-schedule-layout" style={{
|
<div className="ios26-schedule-layout" style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '5fr 2fr',
|
gridTemplateColumns: '5fr 2fr',
|
||||||
gap: 'var(--ios26-spacing)',
|
gap: 'var(--ios26-spacing)',
|
||||||
alignItems: 'stretch',
|
alignItems: 'stretch',
|
||||||
// стабилизируем высоту секции (без фиксированных px),
|
// стабилизируем высоту секции (без фиксированных px),
|
||||||
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
|
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
|
||||||
minHeight: 'calc(100vh - 160px)',
|
minHeight: 'min(calc(100vh - 160px), 600px)',
|
||||||
}}>
|
}}>
|
||||||
<div className="ios26-schedule-calendar-wrap">
|
<div className="ios26-schedule-calendar-wrap">
|
||||||
<Calendar
|
<Calendar
|
||||||
lessons={lessons}
|
lessons={lessons}
|
||||||
lessonsLoading={lessonsLoading}
|
lessonsLoading={lessonsLoading}
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
onSelectSlot={handleSelectSlot}
|
onSelectSlot={handleSelectSlot}
|
||||||
onSelectEvent={handleSelectEvent}
|
onSelectEvent={handleSelectEvent}
|
||||||
onMonthChange={handleMonthChange}
|
onMonthChange={handleMonthChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="ios26-schedule-right-wrap">
|
<div className="ios26-schedule-right-wrap">
|
||||||
<CheckLesson
|
<CheckLesson
|
||||||
selectedDate={selectedDate}
|
selectedDate={selectedDate}
|
||||||
displayDate={displayDate}
|
displayDate={displayDate}
|
||||||
lessonsLoading={lessonsLoading}
|
lessonsLoading={lessonsLoading}
|
||||||
lessonsForSelectedDate={lessonsForSelectedDate}
|
lessonsForSelectedDate={lessonsForSelectedDate}
|
||||||
isFormVisible={isFormVisible}
|
isFormVisible={isFormVisible}
|
||||||
isMentor={isMentor}
|
isMentor={isMentor}
|
||||||
onPrevDay={handlePrevDay}
|
onPrevDay={handlePrevDay}
|
||||||
onNextDay={handleNextDay}
|
onNextDay={handleNextDay}
|
||||||
onAddLesson={handleAddLesson}
|
onAddLesson={handleAddLesson}
|
||||||
onLessonClick={handleLessonClick}
|
onLessonClick={handleLessonClick}
|
||||||
buttonComponentsLoaded={buttonComponentsLoaded}
|
buttonComponentsLoaded={buttonComponentsLoaded}
|
||||||
formComponentsLoaded={formComponentsLoaded}
|
formComponentsLoaded={formComponentsLoaded}
|
||||||
lessonEditLoading={lessonEditLoading}
|
lessonEditLoading={lessonEditLoading}
|
||||||
isEditingMode={isEditingMode}
|
isEditingMode={isEditingMode}
|
||||||
formLoading={formLoading}
|
formLoading={formLoading}
|
||||||
formError={formError}
|
formError={formError}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
setFormData={setFormData}
|
setFormData={setFormData}
|
||||||
selectedSubjectId={selectedSubjectId}
|
selectedSubjectId={selectedSubjectId}
|
||||||
selectedMentorSubjectId={selectedMentorSubjectId}
|
selectedMentorSubjectId={selectedMentorSubjectId}
|
||||||
onSubjectChange={handleSubjectChange}
|
onSubjectChange={handleSubjectChange}
|
||||||
students={students}
|
students={students}
|
||||||
subjects={subjects}
|
subjects={subjects}
|
||||||
mentorSubjects={mentorSubjects}
|
mentorSubjects={mentorSubjects}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
onCancel={handleCancel}
|
onCancel={handleCancel}
|
||||||
onDelete={isEditingMode ? handleDelete : undefined}
|
onDelete={isEditingMode ? handleDelete : undefined}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -416,18 +416,20 @@ export default function StudentsPage() {
|
||||||
className="page-students"
|
className="page-students"
|
||||||
style={{
|
style={{
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
minHeight: '100vh',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */}
|
{/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */}
|
||||||
{(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
|
{(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
|
||||||
<div
|
<div
|
||||||
|
className="students-tabs"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: 4,
|
gap: 4,
|
||||||
marginBottom: 24,
|
marginBottom: 24,
|
||||||
borderBottom: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
|
borderBottom: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
|
overflowX: 'auto',
|
||||||
|
WebkitOverflowScrolling: 'touch',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
|
|
@ -1408,8 +1410,8 @@ export default function StudentsPage() {
|
||||||
sx: {
|
sx: {
|
||||||
mt: 1,
|
mt: 1,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
minWidth: 300,
|
minWidth: { xs: 'calc(100vw - 32px)', sm: 300 },
|
||||||
maxWidth: 360,
|
maxWidth: { xs: 'calc(100vw - 32px)', sm: 360 },
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,221 +1,221 @@
|
||||||
/**
|
/**
|
||||||
* Публичная страница регистрации по ссылке-приглашению (Material UI версия)
|
* Публичная страница регистрации по ссылке-приглашению (Material UI версия)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { getMentorInfoByToken, registerByLink } from '@/api/students';
|
import { getMentorInfoByToken, registerByLink } from '@/api/students';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { getErrorMessage } from '@/lib/error-utils';
|
import { getErrorMessage } from '@/lib/error-utils';
|
||||||
|
|
||||||
const loadMaterialComponents = async () => {
|
const loadMaterialComponents = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
import('@material/web/textfield/filled-text-field.js'),
|
import('@material/web/textfield/filled-text-field.js'),
|
||||||
import('@material/web/button/filled-button.js'),
|
import('@material/web/button/filled-button.js'),
|
||||||
import('@material/web/button/text-button.js'),
|
import('@material/web/button/text-button.js'),
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function InvitationPage() {
|
export default function InvitationPage() {
|
||||||
const { token } = useParams();
|
const { token } = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { login: authLogin } = useAuth();
|
const { login: authLogin } = useAuth();
|
||||||
|
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [mentor, setMentor] = useState<{ mentor_name: string; avatar_url: string | null } | null>(null);
|
const [mentor, setMentor] = useState<{ mentor_name: string; avatar_url: string | null } | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [registering, setRegistering] = useState(false);
|
const [registering, setRegistering] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
const [componentsLoaded, setComponentsLoaded] = useState(false);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
first_name: '',
|
first_name: '',
|
||||||
last_name: '',
|
last_name: '',
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
loadMaterialComponents()
|
loadMaterialComponents()
|
||||||
.then(() => setComponentsLoaded(true))
|
.then(() => setComponentsLoaded(true))
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('Error loading Material components:', err);
|
console.error('Error loading Material components:', err);
|
||||||
setComponentsLoaded(true);
|
setComponentsLoaded(true);
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchMentor = async () => {
|
const fetchMentor = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await getMentorInfoByToken(token as string);
|
const data = await getMentorInfoByToken(token as string);
|
||||||
setMentor(data);
|
setMentor(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError('Недействительная или просроченная ссылка');
|
setError('Недействительная или просроченная ссылка');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
fetchMentor();
|
fetchMentor();
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
setRegistering(true);
|
setRegistering(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await registerByLink({
|
const response = await registerByLink({
|
||||||
token: token as string,
|
token: token as string,
|
||||||
...formData,
|
...formData,
|
||||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Автоматический вход после регистрации
|
// Автоматический вход после регистрации
|
||||||
if (response.access) {
|
if (response.access) {
|
||||||
localStorage.setItem('access_token', response.access);
|
localStorage.setItem('access_token', response.access);
|
||||||
if (response.refresh) {
|
if (response.refresh) {
|
||||||
localStorage.setItem('refresh_token', response.refresh);
|
localStorage.setItem('refresh_token', response.refresh);
|
||||||
}
|
}
|
||||||
if (response.user) {
|
if (response.user) {
|
||||||
await authLogin(response.access, response.user);
|
await authLogin(response.access, response.user);
|
||||||
} else {
|
} else {
|
||||||
await authLogin(response.access);
|
await authLogin(response.access);
|
||||||
}
|
}
|
||||||
window.location.href = '/dashboard';
|
window.location.href = '/dashboard';
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(getErrorMessage(err, 'Ошибка при регистрации'));
|
setError(getErrorMessage(err, 'Ошибка при регистрации'));
|
||||||
setRegistering(false);
|
setRegistering(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!mounted || loading || !componentsLoaded) {
|
if (!mounted || loading || !componentsLoaded) {
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px', height: '100vh', alignItems: 'center', background: '#f8f9fa' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px', height: '100vh', alignItems: 'center', background: '#f8f9fa' }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
width: '40px',
|
width: '40px',
|
||||||
height: '40px',
|
height: '40px',
|
||||||
border: '3px solid #e0e0e0',
|
border: '3px solid #e0e0e0',
|
||||||
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
animation: 'spin 0.8s linear infinite',
|
animation: 'spin 0.8s linear infinite',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error && !mentor) {
|
if (error && !mentor) {
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20 }}>
|
||||||
<div style={{ maxWidth: 400, width: '100%', padding: 32, textAlign: 'center', borderRadius: 24, boxShadow: '0 4px 12px rgba(0,0,0,0.1)', background: '#fff' }}>
|
<div style={{ maxWidth: 400, width: '100%', padding: 32, textAlign: 'center', borderRadius: 24, boxShadow: '0 4px 12px rgba(0,0,0,0.1)', background: '#fff' }}>
|
||||||
<div style={{ color: '#c62828', marginBottom: 16 }}>
|
<div style={{ color: '#c62828', marginBottom: 16 }}>
|
||||||
<svg style={{ width: 64, height: 64 }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg style={{ width: 64, height: 64 }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>Упс!</h1>
|
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>Упс!</h1>
|
||||||
<p style={{ color: '#666', marginBottom: 24 }}>{error}</p>
|
<p style={{ color: '#666', marginBottom: 24 }}>{error}</p>
|
||||||
<md-filled-button onClick={() => router.push('/')} style={{ width: '100%' }}>
|
<md-filled-button onClick={() => router.push('/')} style={{ width: '100%' }}>
|
||||||
На главную
|
На главную
|
||||||
</md-filled-button>
|
</md-filled-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 20, background: 'var(--md-sys-color-surface-container-lowest)' }}>
|
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 20, background: 'var(--md-sys-color-surface-container-lowest)' }}>
|
||||||
<div style={{ maxWidth: '400px', width: '100%' }}>
|
<div style={{ maxWidth: '400px', width: '100%' }}>
|
||||||
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
|
||||||
<div style={{ marginBottom: '24px' }}>
|
<div style={{ marginBottom: '24px' }}>
|
||||||
<img
|
<img
|
||||||
src="/logo/logo.svg"
|
src="/logo/logo.svg"
|
||||||
alt="Uchill Logo"
|
alt="Uchill Logo"
|
||||||
style={{ width: '120px', height: 'auto' }}
|
style={{ width: '120px', height: 'auto' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<h1 style={{ fontSize: '32px', fontWeight: '700', color: 'var(--md-sys-color-on-surface)', marginBottom: '8px' }}>Присоединяйтесь!</h1>
|
<h1 className="invite-title" style={{ fontSize: '32px', fontWeight: '700', color: 'var(--md-sys-color-on-surface)', marginBottom: '8px' }}>Присоединяйтесь!</h1>
|
||||||
<p style={{ fontSize: '16px', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
<p style={{ fontSize: '16px', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
Вас пригласил ментор <span style={{ fontWeight: '600', color: 'var(--md-sys-color-primary)' }}>{mentor?.mentor_name}</span>
|
Вас пригласил ментор <span style={{ fontWeight: '600', color: 'var(--md-sys-color-primary)' }}>{mentor?.mentor_name}</span>
|
||||||
</p>
|
</p>
|
||||||
{mentor?.avatar_url && (
|
{mentor?.avatar_url && (
|
||||||
<div style={{ marginTop: '20px', display: 'flex', justifyContent: 'center' }}>
|
<div style={{ marginTop: '20px', display: 'flex', justifyContent: 'center' }}>
|
||||||
<img
|
<img
|
||||||
src={mentor.avatar_url}
|
src={mentor.avatar_url}
|
||||||
alt={mentor.mentor_name}
|
alt={mentor.mentor_name}
|
||||||
style={{ height: '100px', width: '100px', borderRadius: '50%', objectFit: 'cover', border: '4px solid var(--md-sys-color-surface)', boxShadow: '0 8px 24px rgba(0,0,0,0.12)' }}
|
style={{ height: '100px', width: '100px', borderRadius: '50%', objectFit: 'cover', border: '4px solid var(--md-sys-color-surface)', boxShadow: '0 8px 24px rgba(0,0,0,0.12)' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ padding: '32px', borderRadius: '24px', background: 'var(--md-sys-color-surface)', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}>
|
<div className="invite-form-card" style={{ padding: '32px', borderRadius: '24px', background: 'var(--md-sys-color-surface)', boxShadow: '0 4px 20px rgba(0,0,0,0.08)' }}>
|
||||||
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
<form onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
<div className="auth-name-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||||
<md-filled-text-field
|
<md-filled-text-field
|
||||||
label="Имя"
|
label="Имя"
|
||||||
value={formData.first_name}
|
value={formData.first_name}
|
||||||
onInput={(e: any) => setFormData({ ...formData, first_name: e.target.value })}
|
onInput={(e: any) => setFormData({ ...formData, first_name: e.target.value })}
|
||||||
required
|
required
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
<md-filled-text-field
|
<md-filled-text-field
|
||||||
label="Фамилия"
|
label="Фамилия"
|
||||||
value={formData.last_name}
|
value={formData.last_name}
|
||||||
onInput={(e: any) => setFormData({ ...formData, last_name: e.target.value })}
|
onInput={(e: any) => setFormData({ ...formData, last_name: e.target.value })}
|
||||||
required
|
required
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<md-filled-text-field
|
<md-filled-text-field
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onInput={(e: any) => setFormData({ ...formData, email: e.target.value })}
|
onInput={(e: any) => setFormData({ ...formData, email: e.target.value })}
|
||||||
required
|
required
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<md-filled-text-field
|
<md-filled-text-field
|
||||||
label="Пароль"
|
label="Пароль"
|
||||||
type="password"
|
type="password"
|
||||||
value={formData.password}
|
value={formData.password}
|
||||||
onInput={(e: any) => setFormData({ ...formData, password: e.target.value })}
|
onInput={(e: any) => setFormData({ ...formData, password: e.target.value })}
|
||||||
required
|
required
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div style={{ padding: '12px 16px', borderRadius: '12px', background: 'var(--md-sys-color-error-container)', color: 'var(--md-sys-color-on-error-container)', fontSize: '14px', lineHeight: '1.5' }}>
|
<div style={{ padding: '12px 16px', borderRadius: '12px', background: 'var(--md-sys-color-error-container)', color: 'var(--md-sys-color-on-error-container)', fontSize: '14px', lineHeight: '1.5' }}>
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<md-filled-button
|
<md-filled-button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={registering}
|
disabled={registering}
|
||||||
style={{ height: '56px', fontSize: '16px', fontWeight: '600', borderRadius: '16px' }}
|
style={{ height: '56px', fontSize: '16px', fontWeight: '600', borderRadius: '16px' }}
|
||||||
>
|
>
|
||||||
{registering ? 'Регистрация...' : 'Начать обучение'}
|
{registering ? 'Регистрация...' : 'Начать обучение'}
|
||||||
</md-filled-button>
|
</md-filled-button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ textAlign: 'center', marginTop: '24px' }}>
|
<div style={{ textAlign: 'center', marginTop: '24px' }}>
|
||||||
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
|
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
|
||||||
Уже есть аккаунт? Войти
|
Уже есть аккаунт? Войти
|
||||||
</md-text-button>
|
</md-text-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,48 @@
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import { Providers } from './providers';
|
import { Providers } from './providers';
|
||||||
import '@/styles/globals.css';
|
import '@/styles/globals.css';
|
||||||
import '@/styles/material-theme.css';
|
import '@/styles/material-theme.css';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Uchill Platform',
|
title: 'Uchill Platform',
|
||||||
description: 'Образовательная платформа',
|
description: 'Образовательная платформа',
|
||||||
viewport: 'width=device-width, initial-scale=1, maximum-scale=5',
|
viewport: 'width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover',
|
||||||
themeColor: '#7444FD',
|
themeColor: '#7444FD',
|
||||||
icons: {
|
manifest: '/manifest.json',
|
||||||
icon: '/favicon.png',
|
icons: {
|
||||||
shortcut: '/favicon.png',
|
icon: '/icon.svg',
|
||||||
apple: '/favicon.png',
|
shortcut: '/icon.svg',
|
||||||
},
|
apple: '/icon.svg',
|
||||||
};
|
},
|
||||||
|
appleWebApp: {
|
||||||
export default function RootLayout({
|
capable: true,
|
||||||
children,
|
statusBarStyle: 'black-translucent',
|
||||||
}: {
|
title: 'Uchill',
|
||||||
children: React.ReactNode;
|
},
|
||||||
}) {
|
formatDetection: {
|
||||||
return (
|
telephone: false,
|
||||||
<html lang="ru" suppressHydrationWarning>
|
},
|
||||||
<head>
|
other: {
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
'mobile-web-app-capable': 'yes',
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
},
|
||||||
</head>
|
};
|
||||||
<body>
|
|
||||||
<Providers>
|
export default function RootLayout({
|
||||||
{children}
|
children,
|
||||||
</Providers>
|
}: {
|
||||||
</body>
|
children: React.ReactNode;
|
||||||
</html>
|
}) {
|
||||||
);
|
return (
|
||||||
}
|
<html lang="ru" suppressHydrationWarning>
|
||||||
|
<head>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<Providers>
|
||||||
|
{children}
|
||||||
|
</Providers>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,45 +1,59 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Если пользователь авторизован — редирект на дашборд.
|
* Если пользователь авторизован — редирект на дашборд.
|
||||||
* Страницы логина/регистрации и т.д. не должны быть доступны авторизованным.
|
* Страницы логина/регистрации и т.д. не должны быть доступны авторизованным.
|
||||||
*/
|
*/
|
||||||
export function AuthRedirect({ children }: { children: React.ReactNode }) {
|
export function AuthRedirect({ children }: { children: React.ReactNode }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
if (user) {
|
if (user) {
|
||||||
router.replace('/dashboard');
|
router.replace('/dashboard');
|
||||||
}
|
}
|
||||||
}, [user, loading, router]);
|
}, [user, loading, router]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
minHeight: '100vh',
|
minHeight: '100vh',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
<div style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
Загрузка...
|
Загрузка...
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return null; // редирект уже идёт
|
return (
|
||||||
}
|
<div
|
||||||
|
style={{
|
||||||
return <>{children}</>;
|
minHeight: '100vh',
|
||||||
}
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
|
Вы уже вошли. Перенаправление...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -136,6 +136,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
className="checklesson-root"
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -385,6 +386,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
|
||||||
<>
|
<>
|
||||||
<form
|
<form
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
className="checklesson-form"
|
||||||
style={{
|
style={{
|
||||||
display: 'grid',
|
display: 'grid',
|
||||||
gridTemplateColumns: '1fr 1fr',
|
gridTemplateColumns: '1fr 1fr',
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,32 @@
|
||||||
/**
|
/**
|
||||||
* Material Design 3 Date Picker
|
* Material Design 3 Date Picker — Dialog variant.
|
||||||
|
* Opens a calendar inside a MUI Dialog (works well on mobile and inside other dialogs).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, startOfWeek, endOfWeek } from 'date-fns';
|
import {
|
||||||
|
format,
|
||||||
|
startOfMonth,
|
||||||
|
endOfMonth,
|
||||||
|
eachDayOfInterval,
|
||||||
|
isSameDay,
|
||||||
|
isSameMonth,
|
||||||
|
addMonths,
|
||||||
|
subMonths,
|
||||||
|
startOfWeek,
|
||||||
|
endOfWeek,
|
||||||
|
} from 'date-fns';
|
||||||
import { ru } from 'date-fns/locale';
|
import { ru } from 'date-fns/locale';
|
||||||
|
import { Dialog, DialogContent, Box, Button } from '@mui/material';
|
||||||
|
|
||||||
interface DatePickerProps {
|
interface DatePickerProps {
|
||||||
value: string; // YYYY-MM-DD format
|
value: string; // YYYY-MM-DD format
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DatePicker: React.FC<DatePickerProps> = ({
|
export const DatePicker: React.FC<DatePickerProps> = ({
|
||||||
|
|
@ -20,66 +34,65 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||||
onChange,
|
onChange,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
required = false,
|
required = false,
|
||||||
|
label,
|
||||||
}) => {
|
}) => {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [displayMonth, setDisplayMonth] = useState(value ? new Date(value) : new Date());
|
const [displayMonth, setDisplayMonth] = useState(
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
value ? new Date(value + 'T00:00:00') : new Date(),
|
||||||
|
);
|
||||||
|
|
||||||
const selectedDate = value ? new Date(value) : null;
|
const selectedDate = useMemo(
|
||||||
|
() => (value ? new Date(value + 'T00:00:00') : null),
|
||||||
|
[value],
|
||||||
|
);
|
||||||
|
|
||||||
// Закрываем picker при клике вне компонента
|
const openPicker = () => {
|
||||||
useEffect(() => {
|
if (disabled) return;
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
setDisplayMonth(selectedDate ?? new Date());
|
||||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
setOpen(true);
|
||||||
setIsOpen(false);
|
};
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isOpen) {
|
const closePicker = () => setOpen(false);
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('mousedown', handleClickOutside);
|
|
||||||
};
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const handleDateSelect = (date: Date) => {
|
const handleDateSelect = (date: Date) => {
|
||||||
const year = date.getFullYear();
|
const y = date.getFullYear();
|
||||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
const day = String(date.getDate()).padStart(2, '0');
|
const d = String(date.getDate()).padStart(2, '0');
|
||||||
onChange(`${year}-${month}-${day}`);
|
onChange(`${y}-${m}-${d}`);
|
||||||
setIsOpen(false);
|
closePicker();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Получаем дни для отображения в календаре
|
const days = useMemo(() => {
|
||||||
const getDaysInMonth = () => {
|
|
||||||
const start = startOfMonth(displayMonth);
|
const start = startOfMonth(displayMonth);
|
||||||
const end = endOfMonth(displayMonth);
|
const end = endOfMonth(displayMonth);
|
||||||
const startDate = startOfWeek(start, { locale: ru });
|
return eachDayOfInterval({
|
||||||
const endDate = endOfWeek(end, { locale: ru });
|
start: startOfWeek(start, { locale: ru }),
|
||||||
|
end: endOfWeek(end, { locale: ru }),
|
||||||
return eachDayOfInterval({ start: startDate, end: endDate });
|
});
|
||||||
};
|
}, [displayMonth]);
|
||||||
|
|
||||||
const days = getDaysInMonth();
|
|
||||||
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
|
||||||
|
|
||||||
|
const displayValue = selectedDate
|
||||||
|
? format(selectedDate, 'd MMMM yyyy', { locale: ru })
|
||||||
|
: label || 'Выберите дату';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
|
<>
|
||||||
{/* Input field */}
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => !disabled && setIsOpen(!isOpen)}
|
onClick={openPicker}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-required={required}
|
||||||
style={{
|
style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
fontSize: '16px',
|
fontSize: '16px',
|
||||||
color: value ? 'var(--md-sys-color-on-surface)' : 'var(--md-sys-color-on-surface-variant)',
|
color: value
|
||||||
|
? 'var(--md-sys-color-on-surface)'
|
||||||
|
: 'var(--md-sys-color-on-surface-variant)',
|
||||||
background: 'var(--md-sys-color-surface)',
|
background: 'var(--md-sys-color-surface)',
|
||||||
border: `1px solid ${isOpen ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'}`,
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
borderWidth: isOpen ? '2px' : '1px',
|
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
fontFamily: 'inherit',
|
fontFamily: 'inherit',
|
||||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||||
|
|
@ -92,44 +105,46 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span>
|
<span>{displayValue}</span>
|
||||||
{selectedDate ? format(selectedDate, 'd MMMM yyyy', { locale: ru }) : 'Выберите дату'}
|
<span
|
||||||
|
className="material-symbols-outlined"
|
||||||
|
style={{ fontSize: 20, opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
calendar_today
|
||||||
</span>
|
</span>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
|
||||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
|
||||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
|
||||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Calendar dropdown */}
|
<Dialog
|
||||||
{isOpen && (
|
open={open}
|
||||||
<div style={{
|
onClose={closePicker}
|
||||||
position: 'absolute',
|
fullWidth
|
||||||
top: 'calc(100% + 4px)',
|
maxWidth="xs"
|
||||||
left: 0,
|
slotProps={{
|
||||||
background: 'var(--md-sys-color-surface)',
|
paper: {
|
||||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
sx: {
|
||||||
borderRadius: '16px',
|
borderRadius: '24px',
|
||||||
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)',
|
overflow: 'visible',
|
||||||
zIndex: 1000,
|
bgcolor: 'var(--md-sys-color-surface)',
|
||||||
padding: '16px',
|
},
|
||||||
minWidth: '320px',
|
},
|
||||||
}}>
|
}}
|
||||||
{/* Header with month/year navigation */}
|
>
|
||||||
<div style={{
|
<DialogContent sx={{ p: 2 }}>
|
||||||
display: 'flex',
|
{/* Month/year header */}
|
||||||
justifyContent: 'space-between',
|
<Box
|
||||||
alignItems: 'center',
|
sx={{
|
||||||
marginBottom: '16px',
|
display: 'flex',
|
||||||
}}>
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
mb: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
|
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
|
||||||
style={{
|
style={{
|
||||||
width: '32px',
|
width: 36,
|
||||||
height: '32px',
|
height: 36,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
@ -139,30 +154,30 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
transition: 'background 0.2s ease',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||||
<polyline points="15 18 9 12 15 6"></polyline>
|
chevron_left
|
||||||
</svg>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div style={{
|
<span
|
||||||
fontSize: '16px',
|
style={{
|
||||||
fontWeight: '500',
|
fontSize: 16,
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
fontWeight: 500,
|
||||||
}}>
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
}}
|
||||||
|
>
|
||||||
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
|
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
|
||||||
</div>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
|
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
|
||||||
style={{
|
style={{
|
||||||
width: '32px',
|
width: 36,
|
||||||
height: '32px',
|
height: 36,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
@ -172,33 +187,32 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
transition: 'background 0.2s ease',
|
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
chevron_right
|
||||||
</svg>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</Box>
|
||||||
|
|
||||||
{/* Week days header */}
|
{/* Weekday headers */}
|
||||||
<div style={{
|
<div
|
||||||
display: 'grid',
|
style={{
|
||||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
display: 'grid',
|
||||||
gap: '4px',
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
marginBottom: '8px',
|
gap: 2,
|
||||||
}}>
|
marginBottom: 4,
|
||||||
{weekDays.map(day => (
|
}}
|
||||||
|
>
|
||||||
|
{weekDays.map((day) => (
|
||||||
<div
|
<div
|
||||||
key={day}
|
key={day}
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
fontSize: '12px',
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: 500,
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
padding: '8px 0',
|
padding: '6px 0',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{day}
|
{day}
|
||||||
|
|
@ -206,56 +220,51 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Calendar days grid */}
|
{/* Calendar days */}
|
||||||
<div style={{
|
<div
|
||||||
display: 'grid',
|
style={{
|
||||||
gridTemplateColumns: 'repeat(7, 1fr)',
|
display: 'grid',
|
||||||
gap: '4px',
|
gridTemplateColumns: 'repeat(7, 1fr)',
|
||||||
}}>
|
gap: 2,
|
||||||
{days.map((day, index) => {
|
}}
|
||||||
|
>
|
||||||
|
{days.map((day, idx) => {
|
||||||
const isSelected = selectedDate && isSameDay(day, selectedDate);
|
const isSelected = selectedDate && isSameDay(day, selectedDate);
|
||||||
const isCurrentMonth = isSameMonth(day, displayMonth);
|
const isCurrent = isSameMonth(day, displayMonth);
|
||||||
const isToday = isSameDay(day, new Date());
|
const isToday = isSameDay(day, new Date());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={idx}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleDateSelect(day)}
|
onClick={() => handleDateSelect(day)}
|
||||||
style={{
|
style={{
|
||||||
width: '40px',
|
width: '100%',
|
||||||
height: '40px',
|
aspectRatio: '1',
|
||||||
|
maxWidth: 40,
|
||||||
|
margin: '0 auto',
|
||||||
padding: 0,
|
padding: 0,
|
||||||
background: isSelected
|
background: isSelected
|
||||||
? 'var(--md-sys-color-primary)'
|
? 'var(--md-sys-color-primary)'
|
||||||
: 'transparent',
|
: 'transparent',
|
||||||
border: isToday && !isSelected
|
border:
|
||||||
? '1px solid var(--md-sys-color-primary)'
|
isToday && !isSelected
|
||||||
: 'none',
|
? '1px solid var(--md-sys-color-primary)'
|
||||||
|
: 'none',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
fontSize: '14px',
|
fontSize: 14,
|
||||||
fontWeight: isSelected ? '500' : '400',
|
fontWeight: isSelected ? 600 : 400,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? 'var(--md-sys-color-on-primary)'
|
? 'var(--md-sys-color-on-primary)'
|
||||||
: isCurrentMonth
|
: isCurrent
|
||||||
? 'var(--md-sys-color-on-surface)'
|
? 'var(--md-sys-color-on-surface)'
|
||||||
: 'var(--md-sys-color-on-surface-variant)',
|
: 'var(--md-sys-color-on-surface-variant)',
|
||||||
opacity: isCurrentMonth ? 1 : 0.4,
|
opacity: isCurrent ? 1 : 0.35,
|
||||||
transition: 'all 0.2s ease',
|
transition: 'background 0.15s',
|
||||||
}}
|
|
||||||
onMouseEnter={(e) => {
|
|
||||||
if (!isSelected) {
|
|
||||||
e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)';
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onMouseLeave={(e) => {
|
|
||||||
if (!isSelected) {
|
|
||||||
e.currentTarget.style.background = 'transparent';
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{format(day, 'd')}
|
{format(day, 'd')}
|
||||||
|
|
@ -264,36 +273,44 @@ export const DatePicker: React.FC<DatePickerProps> = ({
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Today button */}
|
{/* Actions */}
|
||||||
<div style={{
|
<Box
|
||||||
marginTop: '16px',
|
sx={{
|
||||||
paddingTop: '16px',
|
display: 'flex',
|
||||||
borderTop: '1px solid var(--md-sys-color-outline-variant)',
|
justifyContent: 'space-between',
|
||||||
display: 'flex',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
mt: 2,
|
||||||
}}>
|
pt: 1.5,
|
||||||
<button
|
borderTop: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
type="button"
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
onClick={() => handleDateSelect(new Date())}
|
onClick={() => handleDateSelect(new Date())}
|
||||||
style={{
|
variant="text"
|
||||||
padding: '8px 16px',
|
sx={{
|
||||||
background: 'transparent',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '20px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: '500',
|
|
||||||
color: 'var(--md-sys-color-primary)',
|
color: 'var(--md-sys-color-primary)',
|
||||||
transition: 'background 0.2s ease',
|
textTransform: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => e.currentTarget.style.background = 'var(--md-sys-color-primary-container)'}
|
|
||||||
onMouseLeave={(e) => e.currentTarget.style.background = 'transparent'}
|
|
||||||
>
|
>
|
||||||
Сегодня
|
Сегодня
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
<Button
|
||||||
</div>
|
onClick={closePicker}
|
||||||
)}
|
variant="text"
|
||||||
</div>
|
sx={{
|
||||||
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
|
textTransform: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 14,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,252 +1,251 @@
|
||||||
/**
|
/**
|
||||||
* Dashboard для студента (роль client) или для выбранного ребёнка (роль parent)
|
* Dashboard для студента (роль client) или для выбранного ребёнка (роль parent)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { getClientDashboard, getChildDashboard, DashboardStats } from '@/api/dashboard';
|
import { getClientDashboard, getChildDashboard, DashboardStats } from '@/api/dashboard';
|
||||||
import { StatCard } from './StatCard';
|
import { StatCard } from './StatCard';
|
||||||
import { LessonCard } from './LessonCard';
|
import { LessonCard } from './LessonCard';
|
||||||
import { HomeworkCard } from './HomeworkCard';
|
import { HomeworkCard } from './HomeworkCard';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
|
||||||
export interface ClientDashboardProps {
|
export interface ClientDashboardProps {
|
||||||
/** Для родителя: id выбранного ребёнка (user_id) — данные загружаются как для этого ребёнка */
|
/** Для родителя: id выбранного ребёнка (user_id) — данные загружаются как для этого ребёнка */
|
||||||
childId?: string | null;
|
childId?: string | null;
|
||||||
/** Для родителя: имя ребёнка для приветствия */
|
/** Для родителя: имя ребёнка для приветствия */
|
||||||
childName?: string | null;
|
childName?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) => {
|
export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) => {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const isParentView = Boolean(childId);
|
const isParentView = Boolean(childId);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
}, [childId]);
|
}, [childId]);
|
||||||
|
|
||||||
const loadDashboard = async () => {
|
const loadDashboard = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = isParentView && childId
|
const data = isParentView && childId
|
||||||
? await getChildDashboard(childId)
|
? await getChildDashboard(childId)
|
||||||
: await getClientDashboard();
|
: await getClientDashboard();
|
||||||
setStats(data);
|
setStats(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error loading dashboard:', err);
|
console.error('Error loading dashboard:', err);
|
||||||
setError(err.message || 'Ошибка загрузки данных');
|
setError(err.message || 'Ошибка загрузки данных');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error && !stats) {
|
if (error && !stats) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'var(--md-sys-color-error)'
|
color: 'var(--md-sys-color-error)'
|
||||||
}}>
|
}}>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={loadDashboard}
|
onClick={loadDashboard}
|
||||||
style={{
|
style={{
|
||||||
marginTop: '16px',
|
marginTop: '16px',
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
background: 'var(--md-sys-color-primary)',
|
background: 'var(--md-sys-color-primary)',
|
||||||
color: 'var(--md-sys-color-on-primary)',
|
color: 'var(--md-sys-color-on-primary)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Попробовать снова
|
Попробовать снова
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
minHeight: '100vh'
|
}}>
|
||||||
}}>
|
{/* Статистика студента */}
|
||||||
{/* Статистика студента */}
|
<div style={{
|
||||||
<div style={{
|
display: 'grid',
|
||||||
display: 'grid',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
gap: '16px',
|
||||||
gap: '16px',
|
marginBottom: '24px'
|
||||||
marginBottom: '24px'
|
}}>
|
||||||
}}>
|
<StatCard
|
||||||
<StatCard
|
title="Занятий всего"
|
||||||
title="Занятий всего"
|
value={loading ? '—' : (stats?.total_lessons || 0)}
|
||||||
value={loading ? '—' : (stats?.total_lessons || 0)}
|
loading={loading}
|
||||||
loading={loading}
|
icon={
|
||||||
icon={
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
</svg>
|
||||||
</svg>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
|
<StatCard
|
||||||
<StatCard
|
title="Пройдено"
|
||||||
title="Пройдено"
|
value={loading ? '—' : (stats?.completed_lessons || 0)}
|
||||||
value={loading ? '—' : (stats?.completed_lessons || 0)}
|
loading={loading}
|
||||||
loading={loading}
|
icon={
|
||||||
icon={
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
</svg>
|
||||||
</svg>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
|
<StatCard
|
||||||
<StatCard
|
title="ДЗ к выполнению"
|
||||||
title="ДЗ к выполнению"
|
value={loading ? '—' : (stats?.homework_pending || 0)}
|
||||||
value={loading ? '—' : (stats?.homework_pending || 0)}
|
loading={loading}
|
||||||
loading={loading}
|
icon={
|
||||||
icon={
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
<polyline points="14 2 14 8 20 8"></polyline>
|
<line x1="16" y1="13" x2="8" y2="13"></line>
|
||||||
<line x1="16" y1="13" x2="8" y2="13"></line>
|
<line x1="16" y1="17" x2="8" y2="17"></line>
|
||||||
<line x1="16" y1="17" x2="8" y2="17"></line>
|
</svg>
|
||||||
</svg>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
|
||||||
|
<StatCard
|
||||||
<StatCard
|
title="Средняя оценка"
|
||||||
title="Средняя оценка"
|
value={loading ? '—' : (stats?.average_grade != null ? String(parseFloat(Number(stats.average_grade).toFixed(2))) : '-')}
|
||||||
value={loading ? '—' : (stats?.average_grade != null ? String(parseFloat(Number(stats.average_grade).toFixed(2))) : '-')}
|
loading={loading}
|
||||||
loading={loading}
|
icon={
|
||||||
icon={
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
||||||
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"></polygon>
|
</svg>
|
||||||
</svg>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Следующее занятие */}
|
||||||
{/* Следующее занятие */}
|
{stats?.next_lesson && (
|
||||||
{stats?.next_lesson && (
|
<div style={{
|
||||||
<div style={{
|
background: 'var(--md-sys-color-surface)',
|
||||||
background: 'var(--md-sys-color-surface)',
|
borderRadius: '20px',
|
||||||
borderRadius: '20px',
|
padding: '24px',
|
||||||
padding: '24px',
|
border: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
border: '1px solid var(--md-sys-color-outline-variant)',
|
marginBottom: '24px',
|
||||||
marginBottom: '24px',
|
borderLeft: '4px solid var(--md-sys-color-primary)'
|
||||||
borderLeft: '4px solid var(--md-sys-color-primary)'
|
}}>
|
||||||
}}>
|
<h3 style={{
|
||||||
<h3 style={{
|
fontSize: '20px',
|
||||||
fontSize: '20px',
|
fontWeight: '500',
|
||||||
fontWeight: '500',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
margin: '0 0 16px 0'
|
||||||
margin: '0 0 16px 0'
|
}}>
|
||||||
}}>
|
Ближайшее занятие
|
||||||
Ближайшее занятие
|
</h3>
|
||||||
</h3>
|
<LessonCard lesson={stats.next_lesson} showMentor />
|
||||||
<LessonCard lesson={stats.next_lesson} showMentor />
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{/* Домашние задания и расписание */}
|
||||||
{/* Домашние задания и расписание */}
|
<div className="client-dashboard-grid" style={{
|
||||||
<div style={{
|
display: 'grid',
|
||||||
display: 'grid',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(min(400px, 100%), 1fr))',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
|
gap: '16px',
|
||||||
gap: '16px',
|
marginBottom: '24px'
|
||||||
marginBottom: '24px'
|
}}>
|
||||||
}}>
|
{/* Домашние задания */}
|
||||||
{/* Домашние задания */}
|
<div style={{
|
||||||
<div style={{
|
background: 'var(--md-sys-color-surface)',
|
||||||
background: 'var(--md-sys-color-surface)',
|
borderRadius: '20px',
|
||||||
borderRadius: '20px',
|
padding: '24px',
|
||||||
padding: '24px',
|
border: '1px solid var(--md-sys-color-outline-variant)'
|
||||||
border: '1px solid var(--md-sys-color-outline-variant)'
|
}}>
|
||||||
}}>
|
<h3 style={{
|
||||||
<h3 style={{
|
fontSize: '20px',
|
||||||
fontSize: '20px',
|
fontWeight: '500',
|
||||||
fontWeight: '500',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
margin: '0 0 20px 0'
|
||||||
margin: '0 0 20px 0'
|
}}>
|
||||||
}}>
|
Ваши домашние задания
|
||||||
Ваши домашние задания
|
</h3>
|
||||||
</h3>
|
{loading ? (
|
||||||
{loading ? (
|
<LoadingSpinner size="medium" />
|
||||||
<LoadingSpinner size="medium" />
|
) : stats?.recent_homework && stats.recent_homework.length > 0 ? (
|
||||||
) : stats?.recent_homework && stats.recent_homework.length > 0 ? (
|
<div>
|
||||||
<div>
|
{stats.recent_homework.slice(0, 3).map((homework) => (
|
||||||
{stats.recent_homework.slice(0, 3).map((homework) => (
|
<HomeworkCard key={homework.id} homework={homework} />
|
||||||
<HomeworkCard key={homework.id} homework={homework} />
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div style={{
|
||||||
<div style={{
|
textAlign: 'center',
|
||||||
textAlign: 'center',
|
padding: '32px',
|
||||||
padding: '32px',
|
color: 'var(--md-sys-color-on-surface-variant)'
|
||||||
color: 'var(--md-sys-color-on-surface-variant)'
|
}}>
|
||||||
}}>
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ margin: '0 auto 16px', opacity: 0.3 }}>
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ margin: '0 auto 16px', opacity: 0.3 }}>
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
|
<polyline points="14 2 14 8 20 8"></polyline>
|
||||||
<polyline points="14 2 14 8 20 8"></polyline>
|
</svg>
|
||||||
</svg>
|
<p style={{ margin: 0 }}>Нет домашних заданий</p>
|
||||||
<p style={{ margin: 0 }}>Нет домашних заданий</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Ближайшие занятия */}
|
||||||
{/* Ближайшие занятия */}
|
<div style={{
|
||||||
<div style={{
|
background: 'var(--md-sys-color-surface)',
|
||||||
background: 'var(--md-sys-color-surface)',
|
borderRadius: '20px',
|
||||||
borderRadius: '20px',
|
padding: '24px',
|
||||||
padding: '24px',
|
border: '1px solid var(--md-sys-color-outline-variant)'
|
||||||
border: '1px solid var(--md-sys-color-outline-variant)'
|
}}>
|
||||||
}}>
|
<h3 style={{
|
||||||
<h3 style={{
|
fontSize: '20px',
|
||||||
fontSize: '20px',
|
fontWeight: '500',
|
||||||
fontWeight: '500',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
margin: '0 0 20px 0'
|
||||||
margin: '0 0 20px 0'
|
}}>
|
||||||
}}>
|
Ваши занятия
|
||||||
Ваши занятия
|
</h3>
|
||||||
</h3>
|
{loading ? (
|
||||||
{loading ? (
|
<LoadingSpinner size="medium" />
|
||||||
<LoadingSpinner size="medium" />
|
) : stats?.upcoming_lessons && stats.upcoming_lessons.length > 0 ? (
|
||||||
) : stats?.upcoming_lessons && stats.upcoming_lessons.length > 0 ? (
|
<div>
|
||||||
<div>
|
{stats.upcoming_lessons.slice(0, 3).map((lesson) => (
|
||||||
{stats.upcoming_lessons.slice(0, 3).map((lesson) => (
|
<LessonCard key={lesson.id} lesson={lesson} showMentor />
|
||||||
<LessonCard key={lesson.id} lesson={lesson} showMentor />
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div style={{
|
||||||
<div style={{
|
textAlign: 'center',
|
||||||
textAlign: 'center',
|
padding: '32px',
|
||||||
padding: '32px',
|
color: 'var(--md-sys-color-on-surface-variant)'
|
||||||
color: 'var(--md-sys-color-on-surface-variant)'
|
}}>
|
||||||
}}>
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ margin: '0 auto 16px', opacity: 0.3 }}>
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" style={{ margin: '0 auto 16px', opacity: 0.3 }}>
|
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
||||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
|
<line x1="16" y1="2" x2="16" y2="6"></line>
|
||||||
<line x1="16" y1="2" x2="16" y2="6"></line>
|
<line x1="8" y1="2" x2="8" y2="6"></line>
|
||||||
<line x1="8" y1="2" x2="8" y2="6"></line>
|
<line x1="3" y1="10" x2="21" y2="10"></line>
|
||||||
<line x1="3" y1="10" x2="21" y2="10"></line>
|
</svg>
|
||||||
</svg>
|
<p style={{ margin: 0 }}>Нет запланированных занятий</p>
|
||||||
<p style={{ margin: 0 }}>Нет запланированных занятий</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
};
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,257 +1,256 @@
|
||||||
/**
|
/**
|
||||||
* Dashboard для родителя
|
* Dashboard для родителя
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { getParentDashboard, DashboardStats } from '@/api/dashboard';
|
import { getParentDashboard, DashboardStats } from '@/api/dashboard';
|
||||||
import { StatCard } from './StatCard';
|
import { StatCard } from './StatCard';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
|
||||||
export const ParentDashboard: React.FC = () => {
|
export const ParentDashboard: React.FC = () => {
|
||||||
const [stats, setStats] = useState<DashboardStats | null>(null);
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadDashboard = async () => {
|
const loadDashboard = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = await getParentDashboard();
|
const data = await getParentDashboard();
|
||||||
setStats(data);
|
setStats(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Error loading dashboard:', err);
|
console.error('Error loading dashboard:', err);
|
||||||
setError(err.message || 'Ошибка загрузки данных');
|
setError(err.message || 'Ошибка загрузки данных');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error && !stats) {
|
if (error && !stats) {
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
padding: '24px',
|
padding: '24px',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'var(--md-sys-color-error)'
|
color: 'var(--md-sys-color-error)'
|
||||||
}}>
|
}}>
|
||||||
<p>{error}</p>
|
<p>{error}</p>
|
||||||
<button
|
<button
|
||||||
onClick={loadDashboard}
|
onClick={loadDashboard}
|
||||||
style={{
|
style={{
|
||||||
marginTop: '16px',
|
marginTop: '16px',
|
||||||
padding: '12px 24px',
|
padding: '12px 24px',
|
||||||
background: 'var(--md-sys-color-primary)',
|
background: 'var(--md-sys-color-primary)',
|
||||||
color: 'var(--md-sys-color-on-primary)',
|
color: 'var(--md-sys-color-on-primary)',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
borderRadius: '20px',
|
borderRadius: '20px',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
fontWeight: '500'
|
fontWeight: '500'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Попробовать снова
|
Попробовать снова
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{
|
<div style={{
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '100%',
|
maxWidth: '100%',
|
||||||
padding: '16px',
|
padding: '16px',
|
||||||
background: 'var(--md-sys-color-background)',
|
background: 'var(--md-sys-color-background)',
|
||||||
minHeight: '100vh'
|
}}>
|
||||||
}}>
|
{/* Приветствие */}
|
||||||
{/* Приветствие */}
|
<div style={{
|
||||||
<div style={{
|
background: 'linear-gradient(135deg, var(--md-sys-color-primary) 0%, var(--md-sys-color-tertiary) 100%)',
|
||||||
background: 'linear-gradient(135deg, var(--md-sys-color-primary) 0%, var(--md-sys-color-tertiary) 100%)',
|
borderRadius: '20px',
|
||||||
borderRadius: '20px',
|
padding: '32px',
|
||||||
padding: '32px',
|
marginBottom: '24px',
|
||||||
marginBottom: '24px',
|
color: 'var(--md-sys-color-on-primary)'
|
||||||
color: 'var(--md-sys-color-on-primary)'
|
}}>
|
||||||
}}>
|
<h1 className="parent-dashboard-title" style={{
|
||||||
<h1 style={{
|
fontSize: '28px',
|
||||||
fontSize: '28px',
|
fontWeight: '500',
|
||||||
fontWeight: '500',
|
margin: '0 0 8px 0'
|
||||||
margin: '0 0 8px 0'
|
}}>
|
||||||
}}>
|
Добро пожаловать! 👨👩👧👦
|
||||||
Добро пожаловать! 👨👩👧👦
|
</h1>
|
||||||
</h1>
|
<p style={{
|
||||||
<p style={{
|
fontSize: '16px',
|
||||||
fontSize: '16px',
|
margin: 0,
|
||||||
margin: 0,
|
opacity: 0.9
|
||||||
opacity: 0.9
|
}}>
|
||||||
}}>
|
Отслеживайте прогресс ваших детей и их успехи в обучении
|
||||||
Отслеживайте прогресс ваших детей и их успехи в обучении
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Статистика по детям */}
|
||||||
{/* Статистика по детям */}
|
{stats?.children_stats && stats.children_stats.length > 0 && (
|
||||||
{stats?.children_stats && stats.children_stats.length > 0 && (
|
<div className="parent-children-grid" style={{
|
||||||
<div style={{
|
display: 'grid',
|
||||||
display: 'grid',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(min(300px, 100%), 1fr))',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
|
gap: '16px',
|
||||||
gap: '16px',
|
marginBottom: '24px'
|
||||||
marginBottom: '24px'
|
}}>
|
||||||
}}>
|
{stats.children_stats.map((child) => (
|
||||||
{stats.children_stats.map((child) => (
|
<div
|
||||||
<div
|
key={child.id}
|
||||||
key={child.id}
|
style={{
|
||||||
style={{
|
background: 'var(--md-sys-color-surface)',
|
||||||
background: 'var(--md-sys-color-surface)',
|
borderRadius: '20px',
|
||||||
borderRadius: '20px',
|
padding: '24px',
|
||||||
padding: '24px',
|
border: '1px solid var(--md-sys-color-outline-variant)'
|
||||||
border: '1px solid var(--md-sys-color-outline-variant)'
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<div style={{
|
||||||
<div style={{
|
display: 'flex',
|
||||||
display: 'flex',
|
alignItems: 'center',
|
||||||
alignItems: 'center',
|
gap: '16px',
|
||||||
gap: '16px',
|
marginBottom: '20px'
|
||||||
marginBottom: '20px'
|
}}>
|
||||||
}}>
|
{child.avatar_url ? (
|
||||||
{child.avatar_url ? (
|
<img
|
||||||
<img
|
src={child.avatar_url}
|
||||||
src={child.avatar_url}
|
alt={child.name}
|
||||||
alt={child.name}
|
style={{
|
||||||
style={{
|
width: '56px',
|
||||||
width: '56px',
|
height: '56px',
|
||||||
height: '56px',
|
borderRadius: '50%',
|
||||||
borderRadius: '50%',
|
objectFit: 'cover'
|
||||||
objectFit: 'cover'
|
}}
|
||||||
}}
|
/>
|
||||||
/>
|
) : (
|
||||||
) : (
|
<div style={{
|
||||||
<div style={{
|
width: '56px',
|
||||||
width: '56px',
|
height: '56px',
|
||||||
height: '56px',
|
borderRadius: '50%',
|
||||||
borderRadius: '50%',
|
background: 'var(--md-sys-color-primary-container)',
|
||||||
background: 'var(--md-sys-color-primary-container)',
|
display: 'flex',
|
||||||
display: 'flex',
|
alignItems: 'center',
|
||||||
alignItems: 'center',
|
justifyContent: 'center',
|
||||||
justifyContent: 'center',
|
color: 'var(--md-sys-color-on-primary-container)',
|
||||||
color: 'var(--md-sys-color-on-primary-container)',
|
fontSize: '24px',
|
||||||
fontSize: '24px',
|
fontWeight: '500'
|
||||||
fontWeight: '500'
|
}}>
|
||||||
}}>
|
{child.name.charAt(0).toUpperCase()}
|
||||||
{child.name.charAt(0).toUpperCase()}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
<div>
|
||||||
<div>
|
<h3 style={{
|
||||||
<h3 style={{
|
fontSize: '18px',
|
||||||
fontSize: '18px',
|
fontWeight: '500',
|
||||||
fontWeight: '500',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
margin: '0 0 4px 0'
|
||||||
margin: '0 0 4px 0'
|
}}>
|
||||||
}}>
|
{child.name}
|
||||||
{child.name}
|
</h3>
|
||||||
</h3>
|
<p style={{
|
||||||
<p style={{
|
fontSize: '14px',
|
||||||
fontSize: '14px',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
margin: 0
|
||||||
margin: 0
|
}}>
|
||||||
}}>
|
{child.completed_lessons} / {child.total_lessons} занятий
|
||||||
{child.completed_lessons} / {child.total_lessons} занятий
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div style={{
|
||||||
<div style={{
|
display: 'flex',
|
||||||
display: 'flex',
|
flexDirection: 'column',
|
||||||
flexDirection: 'column',
|
gap: '12px'
|
||||||
gap: '12px'
|
}}>
|
||||||
}}>
|
<div>
|
||||||
<div>
|
<div style={{
|
||||||
<div style={{
|
display: 'flex',
|
||||||
display: 'flex',
|
justifyContent: 'space-between',
|
||||||
justifyContent: 'space-between',
|
marginBottom: '4px',
|
||||||
marginBottom: '4px',
|
fontSize: '12px',
|
||||||
fontSize: '12px',
|
color: 'var(--md-sys-color-on-surface-variant)'
|
||||||
color: 'var(--md-sys-color-on-surface-variant)'
|
}}>
|
||||||
}}>
|
<span>Прогресс занятий</span>
|
||||||
<span>Прогресс занятий</span>
|
<span>{Math.round((child.completed_lessons / child.total_lessons) * 100)}%</span>
|
||||||
<span>{Math.round((child.completed_lessons / child.total_lessons) * 100)}%</span>
|
</div>
|
||||||
</div>
|
<div style={{
|
||||||
<div style={{
|
width: '100%',
|
||||||
width: '100%',
|
height: '6px',
|
||||||
height: '6px',
|
background: 'var(--md-sys-color-surface-variant)',
|
||||||
background: 'var(--md-sys-color-surface-variant)',
|
borderRadius: '3px',
|
||||||
borderRadius: '3px',
|
overflow: 'hidden'
|
||||||
overflow: 'hidden'
|
}}>
|
||||||
}}>
|
<div style={{
|
||||||
<div style={{
|
height: '100%',
|
||||||
height: '100%',
|
width: `${(child.completed_lessons / child.total_lessons) * 100}%`,
|
||||||
width: `${(child.completed_lessons / child.total_lessons) * 100}%`,
|
background: 'var(--md-sys-color-primary)',
|
||||||
background: 'var(--md-sys-color-primary)',
|
transition: 'width 0.3s ease'
|
||||||
transition: 'width 0.3s ease'
|
}}></div>
|
||||||
}}></div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div style={{
|
||||||
<div style={{
|
display: 'flex',
|
||||||
display: 'flex',
|
justifyContent: 'space-between',
|
||||||
justifyContent: 'space-between',
|
fontSize: '14px'
|
||||||
fontSize: '14px'
|
}}>
|
||||||
}}>
|
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
|
Средняя оценка:
|
||||||
Средняя оценка:
|
</span>
|
||||||
</span>
|
<span style={{
|
||||||
<span style={{
|
fontWeight: '500',
|
||||||
fontWeight: '500',
|
color: 'var(--md-sys-color-on-surface)'
|
||||||
color: 'var(--md-sys-color-on-surface)'
|
}}>
|
||||||
}}>
|
{child.average_grade != null ? String(parseFloat(Number(child.average_grade).toFixed(2))) : '—'}
|
||||||
{child.average_grade != null ? String(parseFloat(Number(child.average_grade).toFixed(2))) : '—'}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div style={{
|
||||||
<div style={{
|
display: 'flex',
|
||||||
display: 'flex',
|
justifyContent: 'space-between',
|
||||||
justifyContent: 'space-between',
|
fontSize: '14px'
|
||||||
fontSize: '14px'
|
}}>
|
||||||
}}>
|
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
|
Домашних заданий:
|
||||||
Домашних заданий:
|
</span>
|
||||||
</span>
|
<span style={{
|
||||||
<span style={{
|
fontWeight: '500',
|
||||||
fontWeight: '500',
|
color: child.homework_pending > 0 ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-tertiary)'
|
||||||
color: child.homework_pending > 0 ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-tertiary)'
|
}}>
|
||||||
}}>
|
{child.homework_pending}
|
||||||
{child.homework_pending}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{/* Общая статистика */}
|
||||||
{/* Общая статистика */}
|
<div style={{
|
||||||
<div style={{
|
display: 'grid',
|
||||||
display: 'grid',
|
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
gap: '16px',
|
||||||
gap: '16px',
|
marginBottom: '24px'
|
||||||
marginBottom: '24px'
|
}}>
|
||||||
}}>
|
<StatCard
|
||||||
<StatCard
|
title="Всего детей"
|
||||||
title="Всего детей"
|
value={loading ? '—' : (stats?.children_count || 0)}
|
||||||
value={loading ? '—' : (stats?.children_count || 0)}
|
loading={loading}
|
||||||
loading={loading}
|
icon={
|
||||||
icon={
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
<svg width="24" height="24" 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"></path>
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
<circle cx="9" cy="7" r="4"></circle>
|
||||||
<circle cx="9" cy="7" r="4"></circle>
|
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
||||||
<path d="M23 21v-2a4 4 0 0 0-3-3.87"></path>
|
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
||||||
<path d="M16 3.13a4 4 0 0 1 0 7.75"></path>
|
</svg>
|
||||||
</svg>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,149 +1,150 @@
|
||||||
/**
|
/**
|
||||||
* Секция «Динамика доходов» для дашборда ментора (iOS 26).
|
* Секция «Динамика доходов» для дашборда ментора (iOS 26).
|
||||||
* Использует переиспользуемые Panel, SectionHeader, SegmentedControl.
|
* Использует переиспользуемые Panel, SectionHeader, SegmentedControl.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { MentorIncomeResponse } from '@/api/dashboard';
|
import { MentorIncomeResponse } from '@/api/dashboard';
|
||||||
import { Panel, SectionHeader, SegmentedControl } from '../ui';
|
import { Panel, SectionHeader, SegmentedControl } from '../ui';
|
||||||
import { RevenueChart } from '../RevenueChart';
|
import { RevenueChart } from '../RevenueChart';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
|
|
||||||
export type IncomePeriod = 'day' | 'week' | 'month';
|
export type IncomePeriod = 'day' | 'week' | 'month';
|
||||||
|
|
||||||
export interface IncomeSectionProps {
|
export interface IncomeSectionProps {
|
||||||
data: MentorIncomeResponse | null;
|
data: MentorIncomeResponse | null;
|
||||||
period: IncomePeriod;
|
period: IncomePeriod;
|
||||||
onPeriodChange: (p: IncomePeriod) => void;
|
onPeriodChange: (p: IncomePeriod) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PERIOD_OPTIONS = [
|
const PERIOD_OPTIONS = [
|
||||||
{ value: 'day' as const, label: 'День' },
|
{ value: 'day' as const, label: 'День' },
|
||||||
{ value: 'week' as const, label: 'Неделя' },
|
{ value: 'week' as const, label: 'Неделя' },
|
||||||
{ value: 'month' as const, label: 'Месяц' },
|
{ value: 'month' as const, label: 'Месяц' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const IncomeSection: React.FC<IncomeSectionProps> = ({
|
export const IncomeSection: React.FC<IncomeSectionProps> = ({
|
||||||
data,
|
data,
|
||||||
period,
|
period,
|
||||||
onPeriodChange,
|
onPeriodChange,
|
||||||
loading,
|
loading,
|
||||||
}) => {
|
}) => {
|
||||||
const totalIncome = Number(data?.summary?.total_income ?? 0);
|
const totalIncome = Number(data?.summary?.total_income ?? 0);
|
||||||
const totalLessons = Number(data?.summary?.total_lessons ?? 0);
|
const totalLessons = Number(data?.summary?.total_lessons ?? 0);
|
||||||
const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0);
|
const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Panel padding="md">
|
<Panel padding="md">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Динамика доходов"
|
title="Динамика доходов"
|
||||||
trailing={
|
trailing={
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
options={PERIOD_OPTIONS}
|
options={PERIOD_OPTIONS}
|
||||||
value={period}
|
value={period}
|
||||||
onChange={onPeriodChange}
|
onChange={onPeriodChange}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<LoadingSpinner size="medium" />
|
<LoadingSpinner size="medium" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<RevenueChart
|
<RevenueChart
|
||||||
data={data?.chart_data ?? []}
|
data={data?.chart_data ?? []}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
period={period}
|
period={period}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
className="income-stats-grid"
|
||||||
display: 'grid',
|
style={{
|
||||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
display: 'grid',
|
||||||
gap: 'var(--ios26-spacing)',
|
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||||
paddingTop: 'var(--ios26-spacing-md)',
|
gap: 'var(--ios26-spacing)',
|
||||||
borderTop: '1px solid var(--ios26-list-divider)',
|
paddingTop: 'var(--ios26-spacing-md)',
|
||||||
minHeight: 72,
|
borderTop: '1px solid var(--ios26-list-divider)',
|
||||||
}}
|
minHeight: 72,
|
||||||
>
|
}}
|
||||||
<div>
|
>
|
||||||
<p
|
<div>
|
||||||
style={{
|
<p
|
||||||
fontSize: 16,
|
style={{
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
fontSize: 16,
|
||||||
margin: '0 0 4px 0',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
}}
|
margin: '0 0 4px 0',
|
||||||
>
|
}}
|
||||||
Всего доход
|
>
|
||||||
</p>
|
Всего доход
|
||||||
<p
|
</p>
|
||||||
style={{
|
<p
|
||||||
fontSize: 21,
|
style={{
|
||||||
fontWeight: 600,
|
fontSize: 21,
|
||||||
color: 'var(--md-sys-color-primary)',
|
fontWeight: 600,
|
||||||
margin: 0,
|
color: 'var(--md-sys-color-primary)',
|
||||||
letterSpacing: '-0.02em',
|
margin: 0,
|
||||||
opacity: data?.summary ? 1 : 0.6,
|
letterSpacing: '-0.02em',
|
||||||
}}
|
opacity: data?.summary ? 1 : 0.6,
|
||||||
>
|
}}
|
||||||
{data?.summary
|
>
|
||||||
? `${Math.round(totalIncome).toLocaleString('ru-RU')} ₽`
|
{data?.summary
|
||||||
: '—'}
|
? `${Math.round(totalIncome).toLocaleString('ru-RU')} ₽`
|
||||||
</p>
|
: '—'}
|
||||||
</div>
|
</p>
|
||||||
<div>
|
</div>
|
||||||
<p
|
<div>
|
||||||
style={{
|
<p
|
||||||
fontSize: 16,
|
style={{
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
fontSize: 16,
|
||||||
margin: '0 0 4px 0',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
}}
|
margin: '0 0 4px 0',
|
||||||
>
|
}}
|
||||||
Занятий
|
>
|
||||||
</p>
|
Занятий
|
||||||
<p
|
</p>
|
||||||
style={{
|
<p
|
||||||
fontSize: 21,
|
style={{
|
||||||
fontWeight: 600,
|
fontSize: 21,
|
||||||
color: 'var(--md-sys-color-primary)',
|
fontWeight: 600,
|
||||||
margin: 0,
|
color: 'var(--md-sys-color-primary)',
|
||||||
letterSpacing: '-0.02em',
|
margin: 0,
|
||||||
opacity: data?.summary ? 1 : 0.6,
|
letterSpacing: '-0.02em',
|
||||||
}}
|
opacity: data?.summary ? 1 : 0.6,
|
||||||
>
|
}}
|
||||||
{data?.summary ? totalLessons : '—'}
|
>
|
||||||
</p>
|
{data?.summary ? totalLessons : '—'}
|
||||||
</div>
|
</p>
|
||||||
<div>
|
</div>
|
||||||
<p
|
<div>
|
||||||
style={{
|
<p
|
||||||
fontSize: 16,
|
style={{
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
fontSize: 16,
|
||||||
margin: '0 0 4px 0',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
}}
|
margin: '0 0 4px 0',
|
||||||
>
|
}}
|
||||||
Средняя цена
|
>
|
||||||
</p>
|
Средняя цена
|
||||||
<p
|
</p>
|
||||||
style={{
|
<p
|
||||||
fontSize: 21,
|
style={{
|
||||||
fontWeight: 600,
|
fontSize: 21,
|
||||||
color: 'var(--md-sys-color-primary)',
|
fontWeight: 600,
|
||||||
margin: 0,
|
color: 'var(--md-sys-color-primary)',
|
||||||
letterSpacing: '-0.02em',
|
margin: 0,
|
||||||
opacity: data?.summary ? 1 : 0.6,
|
letterSpacing: '-0.02em',
|
||||||
}}
|
opacity: data?.summary ? 1 : 0.6,
|
||||||
>
|
}}
|
||||||
{data?.summary
|
>
|
||||||
? `${Math.round(averageLessonPrice).toLocaleString('ru-RU')} ₽`
|
{data?.summary
|
||||||
: '—'}
|
? `${Math.round(averageLessonPrice).toLocaleString('ru-RU')} ₽`
|
||||||
</p>
|
: '—'}
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
</>
|
||||||
</Panel>
|
)}
|
||||||
);
|
</Panel>
|
||||||
};
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,257 +1,258 @@
|
||||||
/**
|
/**
|
||||||
* Секция «Последние сданные ДЗ» для дашборда ментора (iOS 26).
|
* Секция «Последние сданные ДЗ» для дашборда ментора (iOS 26).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { MentorDashboardResponse } from '@/api/dashboard';
|
import { MentorDashboardResponse } from '@/api/dashboard';
|
||||||
import { Panel, SectionHeader, FlipCard } from '../ui';
|
import { Panel, SectionHeader, FlipCard } from '../ui';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { getHomeworkSubmission, type HomeworkSubmission } from '@/api/homework';
|
import { getHomeworkSubmission, type HomeworkSubmission } from '@/api/homework';
|
||||||
|
|
||||||
export interface RecentSubmissionsSectionProps {
|
export interface RecentSubmissionsSectionProps {
|
||||||
data: MentorDashboardResponse | null;
|
data: MentorDashboardResponse | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDateTime = (dateTimeStr: string | null): string => {
|
const formatDateTime = (dateTimeStr: string | null): string => {
|
||||||
if (!dateTimeStr) return '—';
|
if (!dateTimeStr) return '—';
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateTimeStr);
|
const date = new Date(dateTimeStr);
|
||||||
if (isNaN(date.getTime())) return '—';
|
if (isNaN(date.getTime())) return '—';
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffMs = now.getTime() - date.getTime();
|
const diffMs = now.getTime() - date.getTime();
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
const diffDays = Math.floor(diffHours / 24);
|
||||||
|
|
||||||
if (diffHours < 1) {
|
if (diffHours < 1) {
|
||||||
const diffMins = Math.floor(diffMs / (1000 * 60));
|
const diffMins = Math.floor(diffMs / (1000 * 60));
|
||||||
return diffMins <= 1 ? 'только что' : `${diffMins} мин назад`;
|
return diffMins <= 1 ? 'только что' : `${diffMins} мин назад`;
|
||||||
} else if (diffHours < 24) {
|
} else if (diffHours < 24) {
|
||||||
return `${diffHours} ч назад`;
|
return `${diffHours} ч назад`;
|
||||||
} else if (diffDays === 1) {
|
} else if (diffDays === 1) {
|
||||||
return 'Вчера';
|
return 'Вчера';
|
||||||
} else if (diffDays < 7) {
|
} else if (diffDays < 7) {
|
||||||
return `${diffDays} дн назад`;
|
return `${diffDays} дн назад`;
|
||||||
} else {
|
} else {
|
||||||
return date.toLocaleDateString('ru-RU', {
|
return date.toLocaleDateString('ru-RU', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string): string => {
|
const getStatusColor = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'graded':
|
case 'graded':
|
||||||
return 'var(--md-sys-color-tertiary)';
|
return 'var(--md-sys-color-tertiary)';
|
||||||
case 'returned':
|
case 'returned':
|
||||||
return 'var(--md-sys-color-error)';
|
return 'var(--md-sys-color-error)';
|
||||||
case 'submitted':
|
case 'submitted':
|
||||||
return 'var(--md-sys-color-on-surface-variant)';
|
return 'var(--md-sys-color-on-surface-variant)';
|
||||||
default:
|
default:
|
||||||
return 'var(--md-sys-color-on-surface-variant)';
|
return 'var(--md-sys-color-on-surface-variant)';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStatusLabel = (status: string): string => {
|
const getStatusLabel = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'graded':
|
case 'graded':
|
||||||
return 'Проверено';
|
return 'Проверено';
|
||||||
case 'returned':
|
case 'returned':
|
||||||
return 'На доработке';
|
return 'На доработке';
|
||||||
case 'submitted':
|
case 'submitted':
|
||||||
return 'Сдано';
|
return 'Сдано';
|
||||||
default:
|
default:
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> = ({
|
export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> = ({
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
}) => {
|
}) => {
|
||||||
const submissions = data?.recent_submissions?.slice(0, 4) || [];
|
const submissions = data?.recent_submissions?.slice(0, 4) || [];
|
||||||
|
|
||||||
const [flipped, setFlipped] = useState(false);
|
const [flipped, setFlipped] = useState(false);
|
||||||
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
|
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
|
||||||
const [details, setDetails] = useState<HomeworkSubmission | null>(null);
|
const [details, setDetails] = useState<HomeworkSubmission | null>(null);
|
||||||
const [loadingDetails, setLoadingDetails] = useState(false);
|
const [loadingDetails, setLoadingDetails] = useState(false);
|
||||||
|
|
||||||
const selectedPreview = useMemo(
|
const selectedPreview = useMemo(
|
||||||
() => submissions.find((s) => s.id === selectedSubmissionId) || null,
|
() => submissions.find((s) => s.id === selectedSubmissionId) || null,
|
||||||
[submissions, selectedSubmissionId],
|
[submissions, selectedSubmissionId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatFullDateTime = (dateTimeStr: string | null): string => {
|
const formatFullDateTime = (dateTimeStr: string | null): string => {
|
||||||
if (!dateTimeStr) return '—';
|
if (!dateTimeStr) return '—';
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateTimeStr);
|
const date = new Date(dateTimeStr);
|
||||||
if (isNaN(date.getTime())) return '—';
|
if (isNaN(date.getTime())) return '—';
|
||||||
return date.toLocaleString('ru-RU', {
|
return date.toLocaleString('ru-RU', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openSubmissionDetails = async (submissionId: string) => {
|
const openSubmissionDetails = async (submissionId: string) => {
|
||||||
setSelectedSubmissionId(submissionId);
|
setSelectedSubmissionId(submissionId);
|
||||||
setFlipped(true);
|
setFlipped(true);
|
||||||
setLoadingDetails(true);
|
setLoadingDetails(true);
|
||||||
setDetails(null);
|
setDetails(null);
|
||||||
try {
|
try {
|
||||||
const full = await getHomeworkSubmission(submissionId);
|
const full = await getHomeworkSubmission(submissionId);
|
||||||
setDetails(full);
|
setDetails(full);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки решения ДЗ:', error);
|
console.error('Ошибка загрузки решения ДЗ:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDetails(false);
|
setLoadingDetails(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlipCard
|
<FlipCard
|
||||||
flipped={flipped}
|
flipped={flipped}
|
||||||
onFlippedChange={setFlipped}
|
onFlippedChange={setFlipped}
|
||||||
front={
|
front={
|
||||||
<Panel padding="md">
|
<Panel padding="md">
|
||||||
<SectionHeader title="Последние сданные ДЗ" />
|
<SectionHeader title="Последние сданные ДЗ" />
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<LoadingSpinner size="medium" />
|
<LoadingSpinner size="medium" />
|
||||||
) : submissions.length === 0 ? (
|
) : submissions.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: 'var(--ios26-spacing-md) 0',
|
padding: 'var(--ios26-spacing-md) 0',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Нет сданных ДЗ
|
Нет сданных ДЗ
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="recent-submissions-grid"
|
||||||
display: 'grid',
|
style={{
|
||||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
display: 'grid',
|
||||||
gap: 'var(--ios26-spacing-sm)',
|
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||||
}}
|
gap: 'var(--ios26-spacing-sm)',
|
||||||
onClick={(e) => e.stopPropagation()}
|
}}
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
{submissions.map((submission) => {
|
>
|
||||||
const studentName = submission.student?.first_name && submission.student?.last_name
|
{submissions.map((submission) => {
|
||||||
? `${submission.student.first_name} ${submission.student.last_name}`.trim()
|
const studentName = submission.student?.first_name && submission.student?.last_name
|
||||||
: submission.student?.name || 'Студент';
|
? `${submission.student.first_name} ${submission.student.last_name}`.trim()
|
||||||
|
: submission.student?.name || 'Студент';
|
||||||
return (
|
|
||||||
<button
|
return (
|
||||||
key={submission.id}
|
<button
|
||||||
type="button"
|
key={submission.id}
|
||||||
onClick={() => openSubmissionDetails(submission.id)}
|
type="button"
|
||||||
className="ios26-lesson-preview"
|
onClick={() => openSubmissionDetails(submission.id)}
|
||||||
>
|
className="ios26-lesson-preview"
|
||||||
<div className="ios26-lesson-avatar">
|
>
|
||||||
{submission.student?.avatar ? (
|
<div className="ios26-lesson-avatar">
|
||||||
<img src={submission.student.avatar} alt={studentName} />
|
{submission.student?.avatar ? (
|
||||||
) : (
|
<img src={submission.student.avatar} alt={studentName} />
|
||||||
<span>
|
) : (
|
||||||
{(studentName || 'С')[0]?.toUpperCase()}
|
<span>
|
||||||
</span>
|
{(studentName || 'С')[0]?.toUpperCase()}
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
<div className="ios26-lesson-text">
|
</div>
|
||||||
<p className="ios26-lesson-subject">
|
<div className="ios26-lesson-text">
|
||||||
{submission.subject || 'Предмет не указан'}
|
<p className="ios26-lesson-subject">
|
||||||
</p>
|
{submission.subject || 'Предмет не указан'}
|
||||||
<p className="ios26-lesson-student">
|
</p>
|
||||||
{studentName}
|
<p className="ios26-lesson-student">
|
||||||
</p>
|
{studentName}
|
||||||
{submission.score != null && (
|
</p>
|
||||||
<p className="ios26-lesson-datetime">
|
{submission.score != null && (
|
||||||
{submission.score}/5 • {getStatusLabel(submission.status)}
|
<p className="ios26-lesson-datetime">
|
||||||
</p>
|
{submission.score}/5 • {getStatusLabel(submission.status)}
|
||||||
)}
|
</p>
|
||||||
{submission.score == null && (
|
)}
|
||||||
<p className="ios26-lesson-datetime">
|
{submission.score == null && (
|
||||||
{getStatusLabel(submission.status)}
|
<p className="ios26-lesson-datetime">
|
||||||
</p>
|
{getStatusLabel(submission.status)}
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
</button>
|
</div>
|
||||||
);
|
</button>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</Panel>
|
)}
|
||||||
}
|
</Panel>
|
||||||
back={
|
}
|
||||||
<Panel padding="md">
|
back={
|
||||||
<SectionHeader
|
<Panel padding="md">
|
||||||
title="Детали ДЗ"
|
<SectionHeader
|
||||||
trailing={
|
title="Детали ДЗ"
|
||||||
<button
|
trailing={
|
||||||
type="button"
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
setFlipped(false);
|
e.stopPropagation();
|
||||||
}}
|
setFlipped(false);
|
||||||
className="ios26-back-button"
|
}}
|
||||||
>
|
className="ios26-back-button"
|
||||||
Назад
|
>
|
||||||
</button>
|
Назад
|
||||||
}
|
</button>
|
||||||
/>
|
}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
/>
|
||||||
{loadingDetails ? (
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
{loadingDetails ? (
|
||||||
Загрузка...
|
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||||||
</div>
|
Загрузка...
|
||||||
) : details ? (
|
</div>
|
||||||
<div>
|
) : details ? (
|
||||||
<p style={{ fontSize: 18, fontWeight: 700, margin: '0 0 8px 0' }}>
|
<div>
|
||||||
{details.homework?.title || selectedPreview?.homework?.title || 'ДЗ'}
|
<p style={{ fontSize: 18, fontWeight: 700, margin: '0 0 8px 0' }}>
|
||||||
</p>
|
{details.homework?.title || selectedPreview?.homework?.title || 'ДЗ'}
|
||||||
{details.homework?.description && (
|
</p>
|
||||||
<p style={{ fontSize: 14, margin: '0 0 10px 0', lineHeight: 1.5, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
{details.homework?.description && (
|
||||||
{details.homework.description}
|
<p style={{ fontSize: 14, margin: '0 0 10px 0', lineHeight: 1.5, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
</p>
|
{details.homework.description}
|
||||||
)}
|
</p>
|
||||||
<p style={{ fontSize: 14, margin: '0 0 6px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
)}
|
||||||
<strong>Студент:</strong> {details.student?.first_name} {details.student?.last_name}
|
<p style={{ fontSize: 14, margin: '0 0 6px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
</p>
|
<strong>Студент:</strong> {details.student?.first_name} {details.student?.last_name}
|
||||||
<p style={{ fontSize: 14, margin: '0 0 6px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
</p>
|
||||||
<strong>Сдано:</strong> {formatFullDateTime(details.submitted_at)}
|
<p style={{ fontSize: 14, margin: '0 0 6px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
</p>
|
<strong>Сдано:</strong> {formatFullDateTime(details.submitted_at)}
|
||||||
{details.score != null && (
|
</p>
|
||||||
<p style={{ fontSize: 14, fontWeight: 600, margin: '0 0 6px 0', color: getStatusColor(details.status) }}>
|
{details.score != null && (
|
||||||
Оценка: {details.score}/5
|
<p style={{ fontSize: 14, fontWeight: 600, margin: '0 0 6px 0', color: getStatusColor(details.status) }}>
|
||||||
</p>
|
Оценка: {details.score}/5
|
||||||
)}
|
</p>
|
||||||
{details.feedback && (
|
)}
|
||||||
<p style={{ fontSize: 13, margin: '10px 0 0 0', lineHeight: 1.5, fontStyle: 'italic', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
{details.feedback && (
|
||||||
<strong>Отзыв:</strong> {details.feedback}
|
<p style={{ fontSize: 13, margin: '10px 0 0 0', lineHeight: 1.5, fontStyle: 'italic', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
</p>
|
<strong>Отзыв:</strong> {details.feedback}
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
) : (
|
||||||
Не удалось загрузить детали
|
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||||||
</div>
|
Не удалось загрузить детали
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Panel>
|
</div>
|
||||||
}
|
</Panel>
|
||||||
/>
|
}
|
||||||
);
|
/>
|
||||||
};
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,248 +1,249 @@
|
||||||
/**
|
/**
|
||||||
* Секция «Ближайшие занятия» для дашборда ментора (iOS 26).
|
* Секция «Ближайшие занятия» для дашборда ментора (iOS 26).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
import { MentorDashboardResponse } from '@/api/dashboard';
|
import { MentorDashboardResponse } from '@/api/dashboard';
|
||||||
import { Panel, SectionHeader, FlipCard } from '../ui';
|
import { Panel, SectionHeader, FlipCard } from '../ui';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { getLesson, type Lesson } from '@/api/schedule';
|
import { getLesson, type Lesson } from '@/api/schedule';
|
||||||
|
|
||||||
export interface UpcomingLessonsSectionProps {
|
export interface UpcomingLessonsSectionProps {
|
||||||
data: MentorDashboardResponse | null;
|
data: MentorDashboardResponse | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatDateTime = (dateTimeStr: string | null): string => {
|
const formatDateTime = (dateTimeStr: string | null): string => {
|
||||||
if (!dateTimeStr) return '—';
|
if (!dateTimeStr) return '—';
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateTimeStr);
|
const date = new Date(dateTimeStr);
|
||||||
if (isNaN(date.getTime())) return '—';
|
if (isNaN(date.getTime())) return '—';
|
||||||
|
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
const tomorrow = new Date(today);
|
const tomorrow = new Date(today);
|
||||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||||
|
|
||||||
const isToday = date.toDateString() === today.toDateString();
|
const isToday = date.toDateString() === today.toDateString();
|
||||||
const isTomorrow = date.toDateString() === tomorrow.toDateString();
|
const isTomorrow = date.toDateString() === tomorrow.toDateString();
|
||||||
|
|
||||||
const timeStr = date.toLocaleTimeString('ru-RU', {
|
const timeStr = date.toLocaleTimeString('ru-RU', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isToday) {
|
if (isToday) {
|
||||||
return `Сегодня, ${timeStr}`;
|
return `Сегодня, ${timeStr}`;
|
||||||
} else if (isTomorrow) {
|
} else if (isTomorrow) {
|
||||||
return `Завтра, ${timeStr}`;
|
return `Завтра, ${timeStr}`;
|
||||||
} else {
|
} else {
|
||||||
return date.toLocaleDateString('ru-RU', {
|
return date.toLocaleDateString('ru-RU', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UpcomingLessonsSection: React.FC<UpcomingLessonsSectionProps> = ({
|
export const UpcomingLessonsSection: React.FC<UpcomingLessonsSectionProps> = ({
|
||||||
data,
|
data,
|
||||||
loading,
|
loading,
|
||||||
}) => {
|
}) => {
|
||||||
const lessons = data?.upcoming_lessons?.slice(0, 4) || [];
|
const lessons = data?.upcoming_lessons?.slice(0, 4) || [];
|
||||||
|
|
||||||
const [flipped, setFlipped] = useState(false);
|
const [flipped, setFlipped] = useState(false);
|
||||||
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
|
||||||
const [details, setDetails] = useState<Lesson | null>(null);
|
const [details, setDetails] = useState<Lesson | null>(null);
|
||||||
const [loadingDetails, setLoadingDetails] = useState(false);
|
const [loadingDetails, setLoadingDetails] = useState(false);
|
||||||
|
|
||||||
const selectedPreview = useMemo(
|
const selectedPreview = useMemo(
|
||||||
() => lessons.find((l) => l.id === selectedLessonId) || null,
|
() => lessons.find((l) => l.id === selectedLessonId) || null,
|
||||||
[lessons, selectedLessonId],
|
[lessons, selectedLessonId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const formatFullDateTime = (dateTimeStr: string | null): string => {
|
const formatFullDateTime = (dateTimeStr: string | null): string => {
|
||||||
if (!dateTimeStr) return '—';
|
if (!dateTimeStr) return '—';
|
||||||
try {
|
try {
|
||||||
const date = new Date(dateTimeStr);
|
const date = new Date(dateTimeStr);
|
||||||
if (isNaN(date.getTime())) return '—';
|
if (isNaN(date.getTime())) return '—';
|
||||||
return date.toLocaleString('ru-RU', {
|
return date.toLocaleString('ru-RU', {
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openLessonDetails = async (lessonId: string) => {
|
const openLessonDetails = async (lessonId: string) => {
|
||||||
setSelectedLessonId(lessonId);
|
setSelectedLessonId(lessonId);
|
||||||
setFlipped(true);
|
setFlipped(true);
|
||||||
setLoadingDetails(true);
|
setLoadingDetails(true);
|
||||||
setDetails(null);
|
setDetails(null);
|
||||||
try {
|
try {
|
||||||
const full = await getLesson(lessonId);
|
const full = await getLesson(lessonId);
|
||||||
setDetails(full);
|
setDetails(full);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Ошибка загрузки занятия:', error);
|
console.error('Ошибка загрузки занятия:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingDetails(false);
|
setLoadingDetails(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FlipCard
|
<FlipCard
|
||||||
flipped={flipped}
|
flipped={flipped}
|
||||||
onFlippedChange={(v) => {
|
onFlippedChange={(v) => {
|
||||||
// если пользователь кликнул по "пустому месту" в карточке — не хотим случайно закрывать,
|
// если пользователь кликнул по "пустому месту" в карточке — не хотим случайно закрывать,
|
||||||
// но оставим стандартное поведение: переворот по клику на сам контейнер FlipCard.
|
// но оставим стандартное поведение: переворот по клику на сам контейнер FlipCard.
|
||||||
setFlipped(v);
|
setFlipped(v);
|
||||||
}}
|
}}
|
||||||
front={
|
front={
|
||||||
<Panel padding="md">
|
<Panel padding="md">
|
||||||
<SectionHeader title="Ближайшие занятия" />
|
<SectionHeader title="Ближайшие занятия" />
|
||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<LoadingSpinner size="medium" />
|
<LoadingSpinner size="medium" />
|
||||||
) : lessons.length === 0 ? (
|
) : lessons.length === 0 ? (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: 'var(--ios26-spacing-md) 0',
|
padding: 'var(--ios26-spacing-md) 0',
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Нет запланированных занятий
|
Нет запланированных занятий
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="upcoming-lessons-grid"
|
||||||
display: 'grid',
|
style={{
|
||||||
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
display: 'grid',
|
||||||
gap: 'var(--ios26-spacing-sm)',
|
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
|
||||||
}}
|
gap: 'var(--ios26-spacing-sm)',
|
||||||
onClick={(e) => e.stopPropagation()}
|
}}
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
{lessons.map((lesson) => {
|
>
|
||||||
const client = lesson.client as any;
|
{lessons.map((lesson) => {
|
||||||
const fullName =
|
const client = lesson.client as any;
|
||||||
client?.first_name || client?.last_name
|
const fullName =
|
||||||
? `${client.first_name ?? ''} ${client.last_name ?? ''}`.trim()
|
client?.first_name || client?.last_name
|
||||||
: client?.name || 'Студент';
|
? `${client.first_name ?? ''} ${client.last_name ?? ''}`.trim()
|
||||||
|
: client?.name || 'Студент';
|
||||||
return (
|
|
||||||
<button
|
return (
|
||||||
key={lesson.id}
|
<button
|
||||||
type="button"
|
key={lesson.id}
|
||||||
onClick={() => openLessonDetails(lesson.id)}
|
type="button"
|
||||||
className="ios26-lesson-preview"
|
onClick={() => openLessonDetails(lesson.id)}
|
||||||
>
|
className="ios26-lesson-preview"
|
||||||
<div className="ios26-lesson-avatar">
|
>
|
||||||
{client?.avatar ? (
|
<div className="ios26-lesson-avatar">
|
||||||
<img src={client.avatar} alt={fullName} />
|
{client?.avatar ? (
|
||||||
) : (
|
<img src={client.avatar} alt={fullName} />
|
||||||
<span>
|
) : (
|
||||||
{(fullName || 'С')[0]?.toUpperCase()}
|
<span>
|
||||||
</span>
|
{(fullName || 'С')[0]?.toUpperCase()}
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
<div className="ios26-lesson-text">
|
</div>
|
||||||
<p className="ios26-lesson-subject">
|
<div className="ios26-lesson-text">
|
||||||
{lesson.subject || 'Предмет не указан'}
|
<p className="ios26-lesson-subject">
|
||||||
</p>
|
{lesson.subject || 'Предмет не указан'}
|
||||||
<p className="ios26-lesson-student">
|
</p>
|
||||||
{fullName}
|
<p className="ios26-lesson-student">
|
||||||
</p>
|
{fullName}
|
||||||
<p className="ios26-lesson-datetime">
|
</p>
|
||||||
{formatDateTime(lesson.start_time)}
|
<p className="ios26-lesson-datetime">
|
||||||
</p>
|
{formatDateTime(lesson.start_time)}
|
||||||
</div>
|
</p>
|
||||||
</button>
|
</div>
|
||||||
);
|
</button>
|
||||||
})}
|
);
|
||||||
</div>
|
})}
|
||||||
)}
|
</div>
|
||||||
</Panel>
|
)}
|
||||||
}
|
</Panel>
|
||||||
back={
|
}
|
||||||
<Panel padding="md">
|
back={
|
||||||
<SectionHeader
|
<Panel padding="md">
|
||||||
title="Детали занятия"
|
<SectionHeader
|
||||||
trailing={
|
title="Детали занятия"
|
||||||
<button
|
trailing={
|
||||||
type="button"
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
setFlipped(false);
|
e.stopPropagation();
|
||||||
}}
|
setFlipped(false);
|
||||||
className="ios26-back-button"
|
}}
|
||||||
>
|
className="ios26-back-button"
|
||||||
Назад
|
>
|
||||||
</button>
|
Назад
|
||||||
}
|
</button>
|
||||||
/>
|
}
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
/>
|
||||||
{loadingDetails ? (
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
<div
|
{loadingDetails ? (
|
||||||
style={{
|
<div
|
||||||
padding: 'var(--ios26-spacing-md) 0',
|
style={{
|
||||||
textAlign: 'center',
|
padding: 'var(--ios26-spacing-md) 0',
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
textAlign: 'center',
|
||||||
fontSize: 14,
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
}}
|
fontSize: 14,
|
||||||
>
|
}}
|
||||||
Загрузка...
|
>
|
||||||
</div>
|
Загрузка...
|
||||||
) : details ? (
|
</div>
|
||||||
<div>
|
) : details ? (
|
||||||
<p style={{ fontSize: 18, fontWeight: 700, margin: '0 0 8px 0' }}>
|
<div>
|
||||||
{details.title || selectedPreview?.title || 'Занятие'}
|
<p style={{ fontSize: 18, fontWeight: 700, margin: '0 0 8px 0' }}>
|
||||||
</p>
|
{details.title || selectedPreview?.title || 'Занятие'}
|
||||||
{details.subject && (
|
</p>
|
||||||
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
{details.subject && (
|
||||||
Предмет: {details.subject}
|
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
</p>
|
Предмет: {details.subject}
|
||||||
)}
|
</p>
|
||||||
<p style={{ fontSize: 14, margin: '0 0 4px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
)}
|
||||||
<strong>Начало:</strong> {formatFullDateTime(details.start_time)}
|
<p style={{ fontSize: 14, margin: '0 0 4px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
</p>
|
<strong>Начало:</strong> {formatFullDateTime(details.start_time)}
|
||||||
<p style={{ fontSize: 14, margin: '0 0 4px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
</p>
|
||||||
<strong>Окончание:</strong> {formatFullDateTime(details.end_time)}
|
<p style={{ fontSize: 14, margin: '0 0 4px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
</p>
|
<strong>Окончание:</strong> {formatFullDateTime(details.end_time)}
|
||||||
{details.description && (
|
</p>
|
||||||
<p style={{ fontSize: 14, margin: '10px 0 0 0', lineHeight: 1.5 }}>
|
{details.description && (
|
||||||
{details.description}
|
<p style={{ fontSize: 14, margin: '10px 0 0 0', lineHeight: 1.5 }}>
|
||||||
</p>
|
{details.description}
|
||||||
)}
|
</p>
|
||||||
{details.mentor_notes && (
|
)}
|
||||||
<p style={{ fontSize: 13, margin: '10px 0 0 0', lineHeight: 1.5, fontStyle: 'italic', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
{details.mentor_notes && (
|
||||||
<strong>Заметки:</strong> {details.mentor_notes}
|
<p style={{ fontSize: 13, margin: '10px 0 0 0', lineHeight: 1.5, fontStyle: 'italic', color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
</p>
|
<strong>Заметки:</strong> {details.mentor_notes}
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
) : (
|
</div>
|
||||||
<div
|
) : (
|
||||||
style={{
|
<div
|
||||||
padding: 'var(--ios26-spacing-md) 0',
|
style={{
|
||||||
textAlign: 'center',
|
padding: 'var(--ios26-spacing-md) 0',
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
textAlign: 'center',
|
||||||
fontSize: 14,
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
}}
|
fontSize: 14,
|
||||||
>
|
}}
|
||||||
Не удалось загрузить детали
|
>
|
||||||
</div>
|
Не удалось загрузить детали
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Panel>
|
</div>
|
||||||
}
|
</Panel>
|
||||||
/>
|
}
|
||||||
);
|
/>
|
||||||
};
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,47 +1,46 @@
|
||||||
/**
|
/**
|
||||||
* Сетка карточек статистики. Переиспользуется для всех ролей.
|
* Сетка карточек статистики. Переиспользуется для всех ролей.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { StatCard } from '../StatCard';
|
import { StatCard } from '../StatCard';
|
||||||
|
|
||||||
export interface StatsGridItem {
|
export interface StatsGridItem {
|
||||||
title: string;
|
title: string;
|
||||||
value: string | number;
|
value: string | number;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
trend?: { value: number; isPositive: boolean };
|
trend?: { value: number; isPositive: boolean };
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StatsGridProps {
|
export interface StatsGridProps {
|
||||||
items: StatsGridItem[];
|
items: StatsGridItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const StatsGrid: React.FC<StatsGridProps> = ({ items }) => {
|
export const StatsGrid: React.FC<StatsGridProps> = ({ items }) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
className="dashboard-stats-grid"
|
||||||
display: 'grid',
|
style={{
|
||||||
// Для дашборда ментора: 4 карточки в один ряд,
|
display: 'grid',
|
||||||
// чтобы верхняя строка занимала всю ширину.
|
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
||||||
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
|
gap: 'var(--ios26-spacing)',
|
||||||
gap: 'var(--ios26-spacing)',
|
}}
|
||||||
}}
|
>
|
||||||
>
|
{items.map((item, i) => (
|
||||||
{items.map((item, i) => (
|
<StatCard
|
||||||
<StatCard
|
key={i}
|
||||||
key={i}
|
title={item.title}
|
||||||
title={item.title}
|
value={item.value}
|
||||||
value={item.value}
|
icon={item.icon}
|
||||||
icon={item.icon}
|
trend={item.trend}
|
||||||
trend={item.trend}
|
subtitle={item.subtitle}
|
||||||
subtitle={item.subtitle}
|
loading={item.loading}
|
||||||
loading={item.loading}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
};
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -1,305 +1,382 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
|
||||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||||
import type { NavBadges } from '@/api/navBadges';
|
import type { NavBadges } from '@/api/navBadges';
|
||||||
import { ChildSelectorCompact } from '@/components/navigation/ChildSelector';
|
import { ChildSelectorCompact } from '@/components/navigation/ChildSelector';
|
||||||
|
import { useIsMobile } from '@/hooks/useIsMobile';
|
||||||
interface NavigationItem {
|
|
||||||
label: string;
|
interface NavigationItem {
|
||||||
path: string;
|
label: string;
|
||||||
icon: string;
|
path: string;
|
||||||
isProfile?: boolean;
|
icon: string;
|
||||||
}
|
isProfile?: boolean;
|
||||||
|
}
|
||||||
interface User {
|
|
||||||
id?: number;
|
interface User {
|
||||||
first_name?: string;
|
id?: number;
|
||||||
last_name?: string;
|
first_name?: string;
|
||||||
email?: string;
|
last_name?: string;
|
||||||
avatar_url?: string | null;
|
email?: string;
|
||||||
avatar?: string | null;
|
avatar_url?: string | null;
|
||||||
}
|
avatar?: string | null;
|
||||||
|
}
|
||||||
interface BottomNavigationBarProps {
|
|
||||||
userRole?: string;
|
interface BottomNavigationBarProps {
|
||||||
user?: User | null;
|
userRole?: string;
|
||||||
navBadges?: NavBadges | null;
|
user?: User | null;
|
||||||
/** Слот для кнопки уведомлений (на мобильном — 4-й элемент в первом ряду). */
|
navBadges?: NavBadges | null;
|
||||||
notificationsSlot?: React.ReactNode;
|
/** Слот для кнопки уведомлений (на мобильном — 4-й элемент в первом ряду). */
|
||||||
/** Выдвижная панель справа (3 колонки). При клике по пункту вызывается onClose. */
|
notificationsSlot?: React.ReactNode;
|
||||||
slideout?: boolean;
|
/** Выдвижная панель справа (3 колонки). При клике по пункту вызывается onClose. */
|
||||||
onClose?: () => void;
|
slideout?: boolean;
|
||||||
}
|
onClose?: () => void;
|
||||||
|
}
|
||||||
function getAvatarUrl(user: User | null | undefined): string | null {
|
|
||||||
if (!user) return null;
|
function getAvatarUrl(user: User | null | undefined): string | null {
|
||||||
const url = user.avatar_url || user.avatar;
|
if (!user) return null;
|
||||||
if (!url) return null;
|
const url = user.avatar_url || user.avatar;
|
||||||
if (url.startsWith('http')) return url;
|
if (!url) return null;
|
||||||
const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : '';
|
if (url.startsWith('http')) return url;
|
||||||
return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`;
|
const base = typeof window !== 'undefined' ? `${window.location.protocol}//${window.location.hostname}:8123` : '';
|
||||||
}
|
return url.startsWith('/') ? `${base}${url}` : `${base}/${url}`;
|
||||||
|
}
|
||||||
function getBadgeCount(item: NavigationItem, navBadges: NavBadges | null | undefined): number {
|
|
||||||
if (!navBadges) return 0;
|
function getBadgeCount(item: NavigationItem, navBadges: NavBadges | null | undefined): number {
|
||||||
switch (item.path) {
|
if (!navBadges) return 0;
|
||||||
case '/schedule':
|
switch (item.path) {
|
||||||
return navBadges.lessons_today;
|
case '/schedule':
|
||||||
case '/chat':
|
return navBadges.lessons_today;
|
||||||
return navBadges.chat_unread;
|
case '/chat':
|
||||||
case '/homework':
|
return navBadges.chat_unread;
|
||||||
return navBadges.homework_pending;
|
case '/homework':
|
||||||
case '/feedback':
|
return navBadges.homework_pending;
|
||||||
return navBadges.feedback_pending;
|
case '/feedback':
|
||||||
case '/students':
|
return navBadges.feedback_pending;
|
||||||
return navBadges.mentorship_requests_pending ?? 0;
|
case '/students':
|
||||||
default:
|
return navBadges.mentorship_requests_pending ?? 0;
|
||||||
return 0;
|
default:
|
||||||
}
|
return 0;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
export function BottomNavigationBar({ userRole, user, navBadges, notificationsSlot, slideout, onClose }: BottomNavigationBarProps) {
|
|
||||||
const router = useRouter();
|
export function BottomNavigationBar({ userRole, user, navBadges, notificationsSlot, slideout, onClose }: BottomNavigationBarProps) {
|
||||||
const pathname = usePathname();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const pathname = usePathname();
|
||||||
const tabParam = searchParams?.get('tab');
|
const searchParams = useSearchParams();
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const tabParam = searchParams?.get('tab');
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const avatarUrl = getAvatarUrl(user);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const avatarUrl = getAvatarUrl(user);
|
||||||
// Определяем навигационные элементы в зависимости от роли
|
const isMobile = useIsMobile();
|
||||||
const navigationItems = useMemo<NavigationItem[]>(() => {
|
|
||||||
const baseItems: NavigationItem[] = [
|
// Swipe gesture handling (secondary to "More" button)
|
||||||
{ label: 'Главная', path: '/dashboard', icon: 'home' },
|
const navContainerRef = useRef<HTMLDivElement>(null);
|
||||||
{ label: 'Расписание', path: '/schedule', icon: 'calendar_month' },
|
const touchStartY = useRef<number | null>(null);
|
||||||
{ label: 'Чат', path: '/chat', icon: 'chat' },
|
const touchStartX = useRef<number | null>(null);
|
||||||
];
|
|
||||||
|
const SWIPE_THRESHOLD = 30;
|
||||||
let roleItems: NavigationItem[] = [];
|
|
||||||
if (userRole === 'mentor') {
|
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||||
roleItems = [
|
touchStartY.current = e.touches[0].clientY;
|
||||||
{ label: 'Студенты', path: '/students', icon: 'group' },
|
touchStartX.current = e.touches[0].clientX;
|
||||||
{ label: 'Материалы', path: '/materials', icon: 'folder' },
|
}, []);
|
||||||
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
|
|
||||||
{ label: 'Обратная связь', path: '/feedback', icon: 'rate_review' },
|
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
|
||||||
];
|
if (touchStartY.current === null || touchStartX.current === null) return;
|
||||||
} else if (userRole === 'client') {
|
const deltaY = touchStartY.current - e.changedTouches[0].clientY;
|
||||||
roleItems = [
|
const deltaX = Math.abs(touchStartX.current - e.changedTouches[0].clientX);
|
||||||
{ label: 'Материалы', path: '/materials', icon: 'folder' },
|
touchStartY.current = null;
|
||||||
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
|
touchStartX.current = null;
|
||||||
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
|
if (Math.abs(deltaY) < SWIPE_THRESHOLD || deltaX > Math.abs(deltaY)) return;
|
||||||
{ label: 'Мои менторы', path: '/request-mentor', icon: 'person_add' },
|
if (deltaY > 0) {
|
||||||
];
|
setExpanded(true);
|
||||||
} else if (userRole === 'parent') {
|
} else {
|
||||||
// Родитель: те же страницы, что и студент, кроме материалов
|
setExpanded(false);
|
||||||
roleItems = [
|
}
|
||||||
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
|
}, []);
|
||||||
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
|
|
||||||
];
|
// Определяем навигационные элементы в зависимости от роли
|
||||||
}
|
const navigationItems = useMemo<NavigationItem[]>(() => {
|
||||||
|
const baseItems: NavigationItem[] = [
|
||||||
const common: NavigationItem[] = [
|
{ label: 'Главная', path: '/dashboard', icon: 'home' },
|
||||||
...baseItems,
|
{ label: 'Расписание', path: '/schedule', icon: 'calendar_month' },
|
||||||
...roleItems,
|
{ label: 'Чат', path: '/chat', icon: 'chat' },
|
||||||
{ label: 'Профиль', path: '/profile', icon: 'person', isProfile: true },
|
];
|
||||||
];
|
|
||||||
// Аналитика, Тарифы и Рефералы только для ментора
|
let roleItems: NavigationItem[] = [];
|
||||||
if (userRole === 'mentor') {
|
if (userRole === 'mentor') {
|
||||||
common.push(
|
roleItems = [
|
||||||
{ label: 'Аналитика', path: '/analytics', icon: 'analytics' },
|
{ label: 'Студенты', path: '/students', icon: 'group' },
|
||||||
{ label: 'Тарифы', path: '/payment', icon: 'credit_card' },
|
{ label: 'Материалы', path: '/materials', icon: 'folder' },
|
||||||
{ label: 'Рефералы', path: '/referrals', icon: 'group_add' }
|
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
|
||||||
);
|
{ label: 'Обратная связь', path: '/feedback', icon: 'rate_review' },
|
||||||
}
|
];
|
||||||
return common;
|
} else if (userRole === 'client') {
|
||||||
}, [userRole]);
|
roleItems = [
|
||||||
|
{ label: 'Материалы', path: '/materials', icon: 'folder' },
|
||||||
const firstRowItems = navigationItems.slice(0, notificationsSlot ? 3 : 5);
|
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
|
||||||
const restItems = navigationItems.slice(notificationsSlot ? 3 : 5);
|
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
|
||||||
const hasMore = restItems.length > 0;
|
{ label: 'Мои менторы', path: '/request-mentor', icon: 'person_add' },
|
||||||
|
];
|
||||||
// Подсветка активного таба по текущему URL
|
} else if (userRole === 'parent') {
|
||||||
useEffect(() => {
|
// Родитель: те же страницы, что и студент, кроме материалов
|
||||||
const idx = navigationItems.findIndex((item) => {
|
roleItems = [
|
||||||
if (item.path === '/payment') return pathname === '/payment';
|
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
|
||||||
if (item.path === '/analytics') return pathname === '/analytics';
|
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
|
||||||
if (item.path === '/referrals') return pathname === '/referrals';
|
];
|
||||||
if (item.path === '/feedback') return pathname === '/feedback';
|
}
|
||||||
if (item.path === '/homework') return pathname === '/homework';
|
|
||||||
if (item.path === '/profile') return pathname === '/profile' && !tabParam;
|
const common: NavigationItem[] = [
|
||||||
if (item.path === '/request-mentor') return pathname === '/request-mentor';
|
...baseItems,
|
||||||
return pathname?.startsWith(item.path);
|
...roleItems,
|
||||||
});
|
{ label: 'Профиль', path: '/profile', icon: 'person', isProfile: true },
|
||||||
if (idx !== -1) setActiveIndex(idx);
|
];
|
||||||
}, [pathname, navigationItems, tabParam]);
|
// Аналитика, Тарифы и Рефералы только для ментора
|
||||||
|
if (userRole === 'mentor') {
|
||||||
const handleTabClick = (index: number) => {
|
common.push(
|
||||||
const item = navigationItems[index];
|
{ label: 'Аналитика', path: '/analytics', icon: 'analytics' },
|
||||||
if (!item) return;
|
{ label: 'Тарифы', path: '/payment', icon: 'credit_card' },
|
||||||
setActiveIndex(index);
|
{ label: 'Рефералы', path: '/referrals', icon: 'group_add' }
|
||||||
setExpanded(false);
|
);
|
||||||
router.push(item.path);
|
}
|
||||||
onClose?.();
|
return common;
|
||||||
};
|
}, [userRole]);
|
||||||
|
|
||||||
if (!navigationItems.length) return null;
|
// Mobile: first 3 items + "More" button; Desktop: first 3/5 items + notifications
|
||||||
|
const MOBILE_FIRST_ROW_COUNT = 3;
|
||||||
const renderButton = (item: NavigationItem, index: number) => {
|
const desktopFirstCount = notificationsSlot ? 3 : 5;
|
||||||
const isActive = index === activeIndex;
|
const firstRowItems = isMobile
|
||||||
const showAvatar = item.isProfile && (avatarUrl || user);
|
? navigationItems.slice(0, MOBILE_FIRST_ROW_COUNT)
|
||||||
const badgeCount = getBadgeCount(item, navBadges);
|
: navigationItems.slice(0, desktopFirstCount);
|
||||||
return (
|
const restItems = isMobile
|
||||||
<button
|
? navigationItems.slice(MOBILE_FIRST_ROW_COUNT)
|
||||||
key={item.path}
|
: navigationItems.slice(desktopFirstCount);
|
||||||
type="button"
|
const hasMore = restItems.length > 0;
|
||||||
onClick={() => handleTabClick(index)}
|
|
||||||
className={
|
// Подсветка активного таба по текущему URL
|
||||||
'ios26-bottom-nav-button' +
|
useEffect(() => {
|
||||||
(isActive ? ' ios26-bottom-nav-button--active' : '')
|
const idx = navigationItems.findIndex((item) => {
|
||||||
}
|
if (item.path === '/payment') return pathname === '/payment';
|
||||||
>
|
if (item.path === '/analytics') return pathname === '/analytics';
|
||||||
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
if (item.path === '/referrals') return pathname === '/referrals';
|
||||||
{showAvatar ? (
|
if (item.path === '/feedback') return pathname === '/feedback';
|
||||||
<span
|
if (item.path === '/homework') return pathname === '/homework';
|
||||||
className="ios26-bottom-nav-icon"
|
if (item.path === '/profile') return pathname === '/profile' && !tabParam;
|
||||||
style={{
|
if (item.path === '/request-mentor') return pathname === '/request-mentor';
|
||||||
width: 24,
|
return pathname?.startsWith(item.path);
|
||||||
height: 24,
|
});
|
||||||
borderRadius: '50%',
|
if (idx !== -1) setActiveIndex(idx);
|
||||||
overflow: 'hidden',
|
}, [pathname, navigationItems, tabParam]);
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
const handleTabClick = (index: number) => {
|
||||||
alignItems: 'center',
|
const item = navigationItems[index];
|
||||||
justifyContent: 'center',
|
if (!item) return;
|
||||||
background: 'var(--md-sys-color-primary-container)',
|
setActiveIndex(index);
|
||||||
color: 'var(--md-sys-color-primary)',
|
setExpanded(false);
|
||||||
fontSize: 12,
|
router.push(item.path);
|
||||||
fontWeight: 600,
|
onClose?.();
|
||||||
}}
|
};
|
||||||
>
|
|
||||||
{avatarUrl ? (
|
if (!navigationItems.length) return null;
|
||||||
<img
|
|
||||||
src={avatarUrl}
|
const renderButton = (item: NavigationItem, index: number) => {
|
||||||
alt=""
|
const isActive = index === activeIndex;
|
||||||
style={{
|
const showAvatar = item.isProfile && (avatarUrl || user);
|
||||||
width: '100%',
|
const badgeCount = getBadgeCount(item, navBadges);
|
||||||
height: '100%',
|
return (
|
||||||
objectFit: 'cover',
|
<button
|
||||||
}}
|
key={item.path}
|
||||||
/>
|
type="button"
|
||||||
) : (
|
onClick={() => handleTabClick(index)}
|
||||||
user && (user.first_name?.charAt(0) || user.email?.charAt(0) || 'У')
|
className={
|
||||||
)}
|
'ios26-bottom-nav-button' +
|
||||||
</span>
|
(isActive ? ' ios26-bottom-nav-button--active' : '')
|
||||||
) : (
|
}
|
||||||
<span className="material-symbols-outlined ios26-bottom-nav-icon">
|
>
|
||||||
{item.icon}
|
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
</span>
|
{showAvatar ? (
|
||||||
)}
|
<span
|
||||||
{badgeCount > 0 && (
|
className="ios26-bottom-nav-icon"
|
||||||
<span
|
style={{
|
||||||
className="ios26-bottom-nav-badge"
|
width: 24,
|
||||||
style={{
|
height: 24,
|
||||||
position: 'absolute',
|
borderRadius: '50%',
|
||||||
top: -8,
|
overflow: 'hidden',
|
||||||
right: -16,
|
flexShrink: 0,
|
||||||
minWidth: 18,
|
display: 'flex',
|
||||||
height: 18,
|
alignItems: 'center',
|
||||||
borderRadius: 9,
|
justifyContent: 'center',
|
||||||
background: 'var(--md-sys-color-error, #b3261e)',
|
background: 'var(--md-sys-color-primary-container)',
|
||||||
color: '#fff',
|
color: 'var(--md-sys-color-primary)',
|
||||||
fontSize: 11,
|
fontSize: 12,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
display: 'flex',
|
}}
|
||||||
alignItems: 'center',
|
>
|
||||||
justifyContent: 'center',
|
{avatarUrl ? (
|
||||||
padding: '0 4px',
|
<img
|
||||||
boxSizing: 'border-box',
|
src={avatarUrl}
|
||||||
}}
|
alt=""
|
||||||
>
|
style={{
|
||||||
{badgeCount > 99 ? '99+' : badgeCount}
|
width: '100%',
|
||||||
</span>
|
height: '100%',
|
||||||
)}
|
objectFit: 'cover',
|
||||||
</span>
|
}}
|
||||||
<span className="ios26-bottom-nav-label">
|
/>
|
||||||
{item.label}
|
) : (
|
||||||
</span>
|
user && (user.first_name?.charAt(0) || user.email?.charAt(0) || 'У')
|
||||||
</button>
|
)}
|
||||||
);
|
</span>
|
||||||
};
|
) : (
|
||||||
|
<span className="material-symbols-outlined ios26-bottom-nav-icon">
|
||||||
if (slideout) {
|
{item.icon}
|
||||||
return (
|
</span>
|
||||||
<div className="ios26-bottom-nav-slideout">
|
)}
|
||||||
<div className="ios26-bottom-nav ios26-bottom-nav-slideout-inner">
|
{badgeCount > 0 && (
|
||||||
{userRole === 'parent' && (
|
<span
|
||||||
<div style={{ gridColumn: '1 / -1', marginBottom: 8 }}>
|
className="ios26-bottom-nav-badge"
|
||||||
<ChildSelectorCompact />
|
style={{
|
||||||
</div>
|
position: 'absolute',
|
||||||
)}
|
top: -8,
|
||||||
{navigationItems.map((item, i) => renderButton(item, i))}
|
right: -16,
|
||||||
</div>
|
minWidth: 18,
|
||||||
</div>
|
height: 18,
|
||||||
);
|
borderRadius: 9,
|
||||||
}
|
background: 'var(--md-sys-color-error, #b3261e)',
|
||||||
|
color: '#fff',
|
||||||
return (
|
fontSize: 11,
|
||||||
<div
|
fontWeight: 600,
|
||||||
className={
|
display: 'flex',
|
||||||
'ios26-bottom-nav-container' +
|
alignItems: 'center',
|
||||||
(expanded ? ' ios26-bottom-nav-container--expanded' : '')
|
justifyContent: 'center',
|
||||||
}
|
padding: '0 4px',
|
||||||
>
|
boxSizing: 'border-box',
|
||||||
{hasMore && (
|
}}
|
||||||
<button
|
>
|
||||||
type="button"
|
{badgeCount > 99 ? '99+' : badgeCount}
|
||||||
className="ios26-bottom-nav-expand-trigger"
|
</span>
|
||||||
onClick={() => setExpanded((e) => !e)}
|
)}
|
||||||
aria-label={expanded ? 'Свернуть' : 'Развернуть'}
|
</span>
|
||||||
>
|
<span className="ios26-bottom-nav-label">
|
||||||
<span
|
{item.label}
|
||||||
className="material-symbols-outlined ios26-bottom-nav-arrow"
|
</span>
|
||||||
style={{
|
</button>
|
||||||
transform: expanded ? 'rotate(180deg)' : 'none',
|
);
|
||||||
}}
|
};
|
||||||
>
|
|
||||||
keyboard_arrow_up
|
if (slideout) {
|
||||||
</span>
|
return (
|
||||||
</button>
|
<div className="ios26-bottom-nav-slideout">
|
||||||
)}
|
<div className="ios26-bottom-nav ios26-bottom-nav-slideout-inner">
|
||||||
<div className="ios26-bottom-nav">
|
{userRole === 'parent' && (
|
||||||
<div
|
<div style={{ gridColumn: '1 / -1', marginBottom: 8 }}>
|
||||||
className={
|
<ChildSelectorCompact />
|
||||||
'ios26-bottom-nav-first-row' +
|
</div>
|
||||||
(userRole === 'parent' ? ' ios26-bottom-nav-first-row--with-selector' : '') +
|
)}
|
||||||
(notificationsSlot ? ' ios26-bottom-nav-first-row--with-notifications' : '')
|
{navigationItems.map((item, i) => renderButton(item, i))}
|
||||||
}
|
</div>
|
||||||
>
|
</div>
|
||||||
{userRole === 'parent' && <ChildSelectorCompact />}
|
);
|
||||||
{userRole === 'parent' ? (
|
}
|
||||||
<div
|
|
||||||
className={
|
// "More" button for mobile
|
||||||
'ios26-bottom-nav-first-row-buttons' +
|
const renderMoreButton = () => (
|
||||||
(notificationsSlot ? ' ios26-bottom-nav-first-row-buttons--with-notifications' : '')
|
<button
|
||||||
}
|
type="button"
|
||||||
>
|
onClick={() => setExpanded((e) => !e)}
|
||||||
{firstRowItems.map((item, i) => renderButton(item, i))}
|
className={
|
||||||
{notificationsSlot}
|
'ios26-bottom-nav-button' +
|
||||||
</div>
|
(expanded ? ' ios26-bottom-nav-button--active' : '')
|
||||||
) : (
|
}
|
||||||
<>
|
>
|
||||||
{firstRowItems.map((item, i) => renderButton(item, i))}
|
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
{notificationsSlot}
|
<span className="material-symbols-outlined ios26-bottom-nav-icon">
|
||||||
</>
|
{expanded ? 'close' : 'more_horiz'}
|
||||||
)}
|
</span>
|
||||||
</div>
|
</span>
|
||||||
<div
|
<span className="ios26-bottom-nav-label">
|
||||||
className={'ios26-bottom-nav-rest' + (expanded ? ' ios26-bottom-nav-rest--expanded' : '')}
|
{expanded ? 'Закрыть' : 'Ещё'}
|
||||||
>
|
</span>
|
||||||
{restItems.map((item, i) => renderButton(item, (notificationsSlot ? 3 : 5) + i))}
|
</button>
|
||||||
</div>
|
);
|
||||||
</div>
|
|
||||||
</div>
|
// Index offset for rest items
|
||||||
);
|
const restIndexOffset = isMobile ? MOBILE_FIRST_ROW_COUNT : desktopFirstCount;
|
||||||
}
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={navContainerRef}
|
||||||
|
className={
|
||||||
|
'ios26-bottom-nav-container' +
|
||||||
|
(expanded ? ' ios26-bottom-nav-container--expanded' : '')
|
||||||
|
}
|
||||||
|
onTouchStart={hasMore ? handleTouchStart : undefined}
|
||||||
|
onTouchEnd={hasMore ? handleTouchEnd : undefined}
|
||||||
|
>
|
||||||
|
{/* Desktop: swipe handle + arrow trigger */}
|
||||||
|
{hasMore && !isMobile && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="ios26-bottom-nav-swipe-handle"
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
>
|
||||||
|
<div className="ios26-bottom-nav-swipe-handle-bar" />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ios26-bottom-nav-expand-trigger"
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
aria-label={expanded ? 'Свернуть' : 'Развернуть'}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="material-symbols-outlined ios26-bottom-nav-arrow"
|
||||||
|
style={{
|
||||||
|
transform: expanded ? 'rotate(180deg)' : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
keyboard_arrow_up
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="ios26-bottom-nav">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'ios26-bottom-nav-first-row' +
|
||||||
|
(userRole === 'parent' ? ' ios26-bottom-nav-first-row--with-selector' : '') +
|
||||||
|
(!isMobile && notificationsSlot ? ' ios26-bottom-nav-first-row--with-notifications' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{userRole === 'parent' && <ChildSelectorCompact />}
|
||||||
|
{userRole === 'parent' ? (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'ios26-bottom-nav-first-row-buttons' +
|
||||||
|
(!isMobile && notificationsSlot ? ' ios26-bottom-nav-first-row-buttons--with-notifications' : '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{firstRowItems.map((item, i) => renderButton(item, i))}
|
||||||
|
{isMobile && hasMore ? renderMoreButton() : notificationsSlot}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{firstRowItems.map((item, i) => renderButton(item, i))}
|
||||||
|
{isMobile && hasMore ? renderMoreButton() : notificationsSlot}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={'ios26-bottom-nav-rest' + (expanded ? ' ios26-bottom-nav-rest--expanded' : '')}
|
||||||
|
>
|
||||||
|
{restItems.map((item, i) => renderButton(item, restIndexOffset + i))}
|
||||||
|
{/* On mobile: notifications at the end of expanded section */}
|
||||||
|
{isMobile && notificationsSlot && (
|
||||||
|
<div className="ios26-bottom-nav-notifications-in-rest">
|
||||||
|
{notificationsSlot}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,403 +1,424 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useNotifications } from '@/hooks/useNotifications';
|
import { useNotifications } from '@/hooks/useNotifications';
|
||||||
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
|
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
|
||||||
import type { Notification } from '@/api/notifications';
|
import type { Notification } from '@/api/notifications';
|
||||||
|
|
||||||
const BELL_POSITION = { right: 24, bottom: 25 };
|
const BELL_POSITION = { right: 24, bottom: 25 };
|
||||||
const PANEL_WIDTH = 360;
|
const PANEL_WIDTH = 360;
|
||||||
const PANEL_MAX_HEIGHT = '70vh';
|
const PANEL_MAX_HEIGHT = '70vh';
|
||||||
|
|
||||||
function formatDate(s: string) {
|
function formatDate(s: string) {
|
||||||
try {
|
try {
|
||||||
const d = new Date(s);
|
const d = new Date(s);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const sameDay = d.toDateString() === now.toDateString();
|
const sameDay = d.toDateString() === now.toDateString();
|
||||||
if (sameDay) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
if (sameDay) return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotificationItem({
|
function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
onHover,
|
onHover,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
notification: Notification;
|
notification: Notification;
|
||||||
onHover: () => void;
|
onHover: () => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const hoveredRef = useRef(false);
|
const hoveredRef = useRef(false);
|
||||||
const handleMouseEnter = () => {
|
const handleMouseEnter = () => {
|
||||||
hoveredRef.current = true;
|
hoveredRef.current = true;
|
||||||
onHover();
|
onHover();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onMouseEnter={handleMouseEnter}
|
onMouseEnter={handleMouseEnter}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && onClick()}
|
onKeyDown={(e) => e.key === 'Enter' && onClick()}
|
||||||
style={{
|
style={{
|
||||||
padding: '12px 16px',
|
padding: '12px 16px',
|
||||||
borderBottom: '1px solid var(--md-sys-color-outline-variant, #eee)',
|
borderBottom: '1px solid var(--md-sys-color-outline-variant, #eee)',
|
||||||
cursor: notification.action_url ? 'pointer' : 'default',
|
cursor: notification.action_url ? 'pointer' : 'default',
|
||||||
backgroundColor: notification.is_read
|
backgroundColor: notification.is_read
|
||||||
? 'transparent'
|
? 'transparent'
|
||||||
: '#bfa9ff',
|
: '#bfa9ff',
|
||||||
transition: 'background-color 0.15s ease',
|
transition: 'background-color 0.15s ease',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
|
||||||
<div style={{ flex: 1, minWidth: 0 }}>
|
<div style={{ flex: 1, minWidth: 0 }}>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{notification.title || 'Уведомление'}
|
{notification.title || 'Уведомление'}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{notification.message}
|
{notification.message}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatDate(notification.created_at)}
|
{formatDate(notification.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const SCROLL_LOAD_MORE_THRESHOLD = 80;
|
const SCROLL_LOAD_MORE_THRESHOLD = 80;
|
||||||
|
|
||||||
export function NotificationBell({ embedded }: { embedded?: boolean }) {
|
export function NotificationBell({ embedded }: { embedded?: boolean }) {
|
||||||
const refreshNavBadges = useNavBadgesRefresh();
|
const refreshNavBadges = useNavBadgesRefresh();
|
||||||
const {
|
const {
|
||||||
list,
|
list,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
loading,
|
loading,
|
||||||
loadingMore,
|
loadingMore,
|
||||||
hasMore,
|
hasMore,
|
||||||
fetchList,
|
fetchList,
|
||||||
fetchMore,
|
fetchMore,
|
||||||
markOneAsRead,
|
markOneAsRead,
|
||||||
markAllAsRead,
|
markAllAsRead,
|
||||||
} = useNotifications({ onNavBadgesUpdate: refreshNavBadges ?? undefined });
|
} = useNotifications({ onNavBadgesUpdate: refreshNavBadges ?? undefined });
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const panelRef = useRef<HTMLDivElement>(null);
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) fetchList();
|
if (open) fetchList();
|
||||||
}, [open, fetchList]);
|
}, [open, fetchList]);
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el || !hasMore || loadingMore) return;
|
if (!el || !hasMore || loadingMore) return;
|
||||||
const { scrollTop, scrollHeight, clientHeight } = el;
|
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||||
if (scrollTop + clientHeight >= scrollHeight - SCROLL_LOAD_MORE_THRESHOLD) {
|
if (scrollTop + clientHeight >= scrollHeight - SCROLL_LOAD_MORE_THRESHOLD) {
|
||||||
fetchMore();
|
fetchMore();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Порядок не сортируем: API уже отдаёт is_read, -created_at. Иначе при подгрузке непрочитанные из новой страницы уезжали бы наверх, а скролл остаётся внизу.
|
// Порядок не сортируем: API уже отдаёт is_read, -created_at. Иначе при подгрузке непрочитанные из новой страницы уезжали бы наверх, а скролл остаётся внизу.
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const handleClickOutside = (e: MouseEvent) => {
|
const handleClickOutside = (e: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
panelRef.current &&
|
panelRef.current &&
|
||||||
!panelRef.current.contains(e.target as Node) &&
|
!panelRef.current.contains(e.target as Node) &&
|
||||||
!(e.target as HTMLElement).closest('[data-notification-bell]')
|
!(e.target as HTMLElement).closest('[data-notification-bell]')
|
||||||
) {
|
) {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<style>{`
|
<style>{`
|
||||||
.notification-panel-enter {
|
.notification-panel-enter {
|
||||||
transform: translateX(calc(100% + 16px));
|
transform: translateX(calc(100% + 16px));
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
.notification-panel-enter-active {
|
.notification-panel-enter-active {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transition: transform 0.25s ease-out, opacity 0.2s ease-out;
|
transition: transform 0.25s ease-out, opacity 0.2s ease-out;
|
||||||
}
|
}
|
||||||
.notification-panel-exit {
|
.notification-panel-exit {
|
||||||
transform: translateX(0);
|
transform: translateX(0);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
.notification-panel-exit-active {
|
.notification-panel-exit-active {
|
||||||
transform: translateX(calc(100% + 16px));
|
transform: translateX(calc(100% + 16px));
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: transform 0.2s ease-in, opacity 0.15s ease-in;
|
transition: transform 0.2s ease-in, opacity 0.15s ease-in;
|
||||||
}
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
data-notification-bell
|
data-notification-bell
|
||||||
style={
|
style={
|
||||||
embedded
|
embedded
|
||||||
? {
|
? {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
right: BELL_POSITION.right,
|
right: BELL_POSITION.right,
|
||||||
bottom: BELL_POSITION.bottom,
|
bottom: BELL_POSITION.bottom,
|
||||||
zIndex: 9998,
|
zIndex: 9998,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-end',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Панель уведомлений — выезжает справа от колокольчика */}
|
{/* Панель уведомлений — выезжает справа от колокольчика */}
|
||||||
{open && (
|
{open && (
|
||||||
<div
|
<div
|
||||||
ref={panelRef}
|
ref={panelRef}
|
||||||
className="notification-panel-enter-active"
|
className="notification-panel-enter-active notification-panel"
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: embedded ? 'fixed' : 'absolute',
|
||||||
...(embedded
|
...(embedded
|
||||||
? { bottom: '100%', marginBottom: 8, left: '50%', transform: 'translateX(-50%)' }
|
? { bottom: 'auto', left: 8, right: 8, top: 'auto', transform: 'none' }
|
||||||
: { right: 52, bottom: 0 }),
|
: { right: 52, bottom: 0 }),
|
||||||
width: PANEL_WIDTH,
|
width: embedded ? 'auto' : `min(${PANEL_WIDTH}px, calc(100vw - 32px))`,
|
||||||
maxHeight: PANEL_MAX_HEIGHT,
|
maxHeight: PANEL_MAX_HEIGHT,
|
||||||
backgroundColor: 'var(--md-sys-color-surface)',
|
backgroundColor: 'var(--md-sys-color-surface)',
|
||||||
borderRadius: 'var(--ios26-radius-md, 24px)',
|
borderRadius: 'var(--ios26-radius-md, 24px)',
|
||||||
boxShadow: 'var(--ios-shadow-deep, 0 18px 50px rgba(0,0,0,0.12))',
|
boxShadow: 'var(--ios-shadow-deep, 0 18px 50px rgba(0,0,0,0.12))',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
padding: '16px 16px 12px',
|
padding: '16px 16px 12px',
|
||||||
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Уведомления
|
Уведомления
|
||||||
</span>
|
</span>
|
||||||
{unreadCount > 0 && (
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||||
<button
|
{unreadCount > 0 && (
|
||||||
type="button"
|
<button
|
||||||
onClick={markAllAsRead}
|
type="button"
|
||||||
style={{
|
onClick={markAllAsRead}
|
||||||
padding: '6px 12px',
|
style={{
|
||||||
fontSize: 13,
|
padding: '6px 12px',
|
||||||
fontWeight: 500,
|
fontSize: 13,
|
||||||
color: 'var(--md-sys-color-primary)',
|
fontWeight: 500,
|
||||||
background: 'transparent',
|
color: 'var(--md-sys-color-primary)',
|
||||||
border: 'none',
|
background: 'transparent',
|
||||||
borderRadius: 8,
|
border: 'none',
|
||||||
cursor: 'pointer',
|
borderRadius: 8,
|
||||||
}}
|
cursor: 'pointer',
|
||||||
>
|
}}
|
||||||
Прочитать все
|
>
|
||||||
</button>
|
Прочитать все
|
||||||
)}
|
</button>
|
||||||
</div>
|
)}
|
||||||
<div
|
<button
|
||||||
ref={scrollRef}
|
type="button"
|
||||||
onScroll={handleScroll}
|
onClick={() => setOpen(false)}
|
||||||
style={{
|
aria-label="Закрыть"
|
||||||
overflowY: 'auto',
|
style={{
|
||||||
overflowX: 'hidden',
|
all: 'unset',
|
||||||
flex: 1,
|
cursor: 'pointer',
|
||||||
minHeight: 120,
|
display: 'flex',
|
||||||
}}
|
alignItems: 'center',
|
||||||
>
|
justifyContent: 'center',
|
||||||
{loading ? (
|
width: 32,
|
||||||
<div
|
height: 32,
|
||||||
style={{
|
borderRadius: '50%',
|
||||||
padding: 24,
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
textAlign: 'center',
|
flexShrink: 0,
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
}}
|
||||||
fontSize: 14,
|
>
|
||||||
}}
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>close</span>
|
||||||
>
|
</button>
|
||||||
Загрузка…
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : list.length === 0 ? (
|
<div
|
||||||
<div
|
ref={scrollRef}
|
||||||
style={{
|
onScroll={handleScroll}
|
||||||
padding: 24,
|
style={{
|
||||||
textAlign: 'center',
|
overflowY: 'auto',
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
overflowX: 'hidden',
|
||||||
fontSize: 14,
|
flex: 1,
|
||||||
}}
|
minHeight: 120,
|
||||||
>
|
}}
|
||||||
Нет уведомлений
|
>
|
||||||
</div>
|
{loading ? (
|
||||||
) : (
|
<div
|
||||||
<>
|
style={{
|
||||||
{list.map((n) => (
|
padding: 24,
|
||||||
<NotificationItem
|
textAlign: 'center',
|
||||||
key={n.id}
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
notification={n}
|
fontSize: 14,
|
||||||
onHover={() => !n.is_read && markOneAsRead(n.id)}
|
}}
|
||||||
onClick={() => {
|
>
|
||||||
/* Не переходим по action_url — только открыта панель уведомлений */
|
Загрузка…
|
||||||
}}
|
</div>
|
||||||
/>
|
) : list.length === 0 ? (
|
||||||
))}
|
<div
|
||||||
{loadingMore && (
|
style={{
|
||||||
<div
|
padding: 24,
|
||||||
style={{
|
textAlign: 'center',
|
||||||
padding: 12,
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
textAlign: 'center',
|
fontSize: 14,
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
}}
|
||||||
fontSize: 13,
|
>
|
||||||
}}
|
Нет уведомлений
|
||||||
>
|
</div>
|
||||||
Загрузка…
|
) : (
|
||||||
</div>
|
<>
|
||||||
)}
|
{list.map((n) => (
|
||||||
</>
|
<NotificationItem
|
||||||
)}
|
key={n.id}
|
||||||
</div>
|
notification={n}
|
||||||
</div>
|
onHover={() => !n.is_read && markOneAsRead(n.id)}
|
||||||
)}
|
onClick={() => {
|
||||||
|
/* Не переходим по action_url — только открыта панель уведомлений */
|
||||||
{/* Кнопка-колокольчик: в меню — как пункт навигации, иначе — круглая */}
|
}}
|
||||||
{embedded ? (
|
/>
|
||||||
<button
|
))}
|
||||||
type="button"
|
{loadingMore && (
|
||||||
className="ios26-bottom-nav-button"
|
<div
|
||||||
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
|
style={{
|
||||||
onClick={() => setOpen((o) => !o)}
|
padding: 12,
|
||||||
>
|
textAlign: 'center',
|
||||||
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
<span className="material-symbols-outlined ios26-bottom-nav-icon">
|
fontSize: 13,
|
||||||
notifications
|
}}
|
||||||
</span>
|
>
|
||||||
{unreadCount > 0 && (
|
Загрузка…
|
||||||
<span
|
</div>
|
||||||
className="ios26-bottom-nav-badge"
|
)}
|
||||||
style={{
|
</>
|
||||||
position: 'absolute',
|
)}
|
||||||
top: -8,
|
</div>
|
||||||
right: -16,
|
</div>
|
||||||
minWidth: 18,
|
)}
|
||||||
height: 18,
|
|
||||||
borderRadius: 9,
|
{/* Кнопка-колокольчик: в меню — как пункт навигации, иначе — круглая */}
|
||||||
background: 'var(--md-sys-color-error, #b3261e)',
|
{embedded ? (
|
||||||
color: '#fff',
|
<button
|
||||||
fontSize: 11,
|
type="button"
|
||||||
fontWeight: 600,
|
className="ios26-bottom-nav-button"
|
||||||
display: 'flex',
|
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
|
||||||
alignItems: 'center',
|
onClick={() => setOpen((o) => !o)}
|
||||||
justifyContent: 'center',
|
>
|
||||||
padding: '0 4px',
|
<span style={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
boxSizing: 'border-box',
|
<span className="material-symbols-outlined ios26-bottom-nav-icon">
|
||||||
}}
|
notifications
|
||||||
title={`${unreadCount} непрочитанных`}
|
</span>
|
||||||
>
|
{unreadCount > 0 && (
|
||||||
{unreadCount > 99 ? '99+' : unreadCount}
|
<span
|
||||||
</span>
|
className="ios26-bottom-nav-badge"
|
||||||
)}
|
style={{
|
||||||
</span>
|
position: 'absolute',
|
||||||
<span className="ios26-bottom-nav-label">Уведомления</span>
|
top: -8,
|
||||||
</button>
|
right: -16,
|
||||||
) : (
|
minWidth: 18,
|
||||||
<button
|
height: 18,
|
||||||
type="button"
|
borderRadius: 9,
|
||||||
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
|
background: 'var(--md-sys-color-error, #b3261e)',
|
||||||
onClick={() => setOpen((o) => !o)}
|
color: '#fff',
|
||||||
style={{
|
fontSize: 11,
|
||||||
position: 'relative',
|
fontWeight: 600,
|
||||||
width: 48,
|
display: 'flex',
|
||||||
height: 48,
|
alignItems: 'center',
|
||||||
borderRadius: '50%',
|
justifyContent: 'center',
|
||||||
border: 'none',
|
padding: '0 4px',
|
||||||
background: 'var(--md-sys-color-primary-container)',
|
boxSizing: 'border-box',
|
||||||
color: 'var(--md-sys-color-primary)',
|
}}
|
||||||
cursor: 'pointer',
|
title={`${unreadCount} непрочитанных`}
|
||||||
display: 'flex',
|
>
|
||||||
alignItems: 'center',
|
{unreadCount > 99 ? '99+' : unreadCount}
|
||||||
justifyContent: 'center',
|
</span>
|
||||||
boxShadow: 'var(--ios-shadow-soft)',
|
)}
|
||||||
}}
|
</span>
|
||||||
>
|
<span className="ios26-bottom-nav-label">Уведомления</span>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
|
</button>
|
||||||
notifications
|
) : (
|
||||||
</span>
|
<button
|
||||||
{unreadCount > 0 && (
|
type="button"
|
||||||
<span
|
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
|
||||||
style={{
|
onClick={() => setOpen((o) => !o)}
|
||||||
position: 'absolute',
|
style={{
|
||||||
top: -2,
|
position: 'relative',
|
||||||
right: -2,
|
width: 48,
|
||||||
minWidth: 18,
|
height: 48,
|
||||||
height: 18,
|
borderRadius: '50%',
|
||||||
padding: '0 5px',
|
border: 'none',
|
||||||
borderRadius: 9,
|
background: 'var(--md-sys-color-primary-container)',
|
||||||
backgroundColor: 'var(--md-sys-color-error, #c00)',
|
color: 'var(--md-sys-color-primary)',
|
||||||
color: '#fff',
|
cursor: 'pointer',
|
||||||
fontSize: 11,
|
display: 'flex',
|
||||||
fontWeight: 700,
|
alignItems: 'center',
|
||||||
display: 'flex',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
boxShadow: 'var(--ios-shadow-soft)',
|
||||||
justifyContent: 'center',
|
}}
|
||||||
maxWidth: 48,
|
>
|
||||||
overflow: 'hidden',
|
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
|
||||||
textOverflow: 'ellipsis',
|
notifications
|
||||||
}}
|
</span>
|
||||||
title={`${unreadCount} непрочитанных`}
|
{unreadCount > 0 && (
|
||||||
>
|
<span
|
||||||
{unreadCount}
|
style={{
|
||||||
</span>
|
position: 'absolute',
|
||||||
)}
|
top: -2,
|
||||||
</button>
|
right: -2,
|
||||||
)}
|
minWidth: 18,
|
||||||
</div>
|
height: 18,
|
||||||
</>
|
padding: '0 5px',
|
||||||
);
|
borderRadius: 9,
|
||||||
}
|
backgroundColor: 'var(--md-sys-color-error, #c00)',
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 700,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
maxWidth: 48,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
}}
|
||||||
|
title={`${unreadCount} непрочитанных`}
|
||||||
|
>
|
||||||
|
{unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,205 +1,207 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getReferralProfile, getReferralStats, getMyReferrals, type MyReferralItem } from '@/api/referrals';
|
import { getReferralProfile, getReferralStats, getMyReferrals, type MyReferralItem } from '@/api/referrals';
|
||||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||||
import { useToast } from '@/contexts/ToastContext';
|
import { useToast } from '@/contexts/ToastContext';
|
||||||
|
|
||||||
const formatCurrency = (v: number) =>
|
const formatCurrency = (v: number) =>
|
||||||
new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v);
|
new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v);
|
||||||
|
|
||||||
function formatDate(s: string) {
|
function formatDate(s: string) {
|
||||||
try {
|
try {
|
||||||
return new Date(s).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
return new Date(s).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||||||
} catch {
|
} catch {
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ReferralsPageContent() {
|
export function ReferralsPageContent() {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [profile, setProfile] = useState<any>(null);
|
const [profile, setProfile] = useState<any>(null);
|
||||||
const [stats, setStats] = useState<any>(null);
|
const [stats, setStats] = useState<any>(null);
|
||||||
const [referralsList, setReferralsList] = useState<{ direct: MyReferralItem[]; indirect: MyReferralItem[] } | null>(null);
|
const [referralsList, setReferralsList] = useState<{ direct: MyReferralItem[]; indirect: MyReferralItem[] } | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
getReferralProfile().then(setProfile),
|
getReferralProfile().then(setProfile),
|
||||||
getReferralStats().then(setStats),
|
getReferralStats().then(setStats),
|
||||||
getMyReferrals().then(setReferralsList).catch(() => setReferralsList({ direct: [], indirect: [] })),
|
getMyReferrals().then(setReferralsList).catch(() => setReferralsList({ direct: [], indirect: [] })),
|
||||||
])
|
])
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const copyLink = () => {
|
const copyLink = () => {
|
||||||
if (profile?.referral_link) {
|
if (profile?.referral_link) {
|
||||||
navigator.clipboard.writeText(profile.referral_link);
|
navigator.clipboard.writeText(profile.referral_link);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
showToast('Реферальная ссылка скопирована', 'success');
|
showToast('Реферальная ссылка скопирована', 'success');
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <LoadingSpinner size="medium" />;
|
return <LoadingSpinner size="medium" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!profile) {
|
if (!profile) {
|
||||||
return (
|
return (
|
||||||
<p style={{ color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
<p style={{ color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
|
||||||
Реферальная программа недоступна
|
Реферальная программа недоступна
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
letterSpacing: '0.05em',
|
letterSpacing: '0.05em',
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
РЕФЕРАЛЬНАЯ ССЫЛКА
|
РЕФЕРАЛЬНАЯ ССЫЛКА
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', gap: 8 }}>
|
<div className="referral-link-row" style={{ display: 'flex', gap: 8 }}>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
readOnly
|
readOnly
|
||||||
value={profile.referral_link || ''}
|
value={profile.referral_link || ''}
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: '12px 16px',
|
minWidth: 0,
|
||||||
borderRadius: 12,
|
padding: '12px 16px',
|
||||||
border: '1px solid var(--md-sys-color-outline)',
|
borderRadius: 12,
|
||||||
background: 'var(--md-sys-color-surface-container-low)',
|
border: '1px solid var(--md-sys-color-outline)',
|
||||||
color: 'var(--md-sys-color-on-surface)',
|
background: 'var(--md-sys-color-surface-container-low)',
|
||||||
fontSize: 14,
|
color: 'var(--md-sys-color-on-surface)',
|
||||||
}}
|
fontSize: 14,
|
||||||
/>
|
}}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
onClick={copyLink}
|
type="button"
|
||||||
style={{
|
onClick={copyLink}
|
||||||
padding: '12px 20px',
|
style={{
|
||||||
borderRadius: 12,
|
padding: '12px 20px',
|
||||||
border: 'none',
|
borderRadius: 12,
|
||||||
background: 'var(--md-sys-color-primary)',
|
border: 'none',
|
||||||
color: 'var(--md-sys-color-on-primary)',
|
background: 'var(--md-sys-color-primary)',
|
||||||
fontSize: 14,
|
color: 'var(--md-sys-color-on-primary)',
|
||||||
fontWeight: 500,
|
fontSize: 14,
|
||||||
cursor: 'pointer',
|
fontWeight: 500,
|
||||||
}}
|
cursor: 'pointer',
|
||||||
>
|
}}
|
||||||
{copied ? 'Скопировано' : 'Копировать'}
|
>
|
||||||
</button>
|
{copied ? 'Скопировано' : 'Копировать'}
|
||||||
</div>
|
</button>
|
||||||
{profile.referral_code && (
|
</div>
|
||||||
<p style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 8 }}>
|
{profile.referral_code && (
|
||||||
Код: <strong>{profile.referral_code}</strong>
|
<p style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 8 }}>
|
||||||
</p>
|
Код: <strong>{profile.referral_code}</strong>
|
||||||
)}
|
</p>
|
||||||
</div>
|
)}
|
||||||
{stats && (
|
</div>
|
||||||
<div
|
{stats && (
|
||||||
style={{
|
<div
|
||||||
display: 'grid',
|
className="referral-stats-grid"
|
||||||
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
style={{
|
||||||
gap: 12,
|
display: 'grid',
|
||||||
}}
|
gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
|
||||||
>
|
gap: 12,
|
||||||
<div className="ios26-panel" style={{ padding: 16 }}>
|
}}
|
||||||
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
|
>
|
||||||
Уровень
|
<div className="ios26-panel" style={{ padding: 16 }}>
|
||||||
</div>
|
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
|
Уровень
|
||||||
{stats.current_level?.name || '-'}
|
</div>
|
||||||
</div>
|
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
|
||||||
</div>
|
{stats.current_level?.name || '-'}
|
||||||
<div className="ios26-panel" style={{ padding: 16 }}>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
|
</div>
|
||||||
Баллы
|
<div className="ios26-panel" style={{ padding: 16 }}>
|
||||||
</div>
|
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
|
Баллы
|
||||||
{stats.total_points ?? 0}
|
</div>
|
||||||
</div>
|
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
|
||||||
</div>
|
{stats.total_points ?? 0}
|
||||||
<div className="ios26-panel" style={{ padding: 16 }}>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
|
</div>
|
||||||
Рефералов
|
<div className="ios26-panel" style={{ padding: 16 }}>
|
||||||
</div>
|
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
|
Рефералов
|
||||||
{stats.referrals?.total ?? 0}
|
</div>
|
||||||
</div>
|
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
|
||||||
</div>
|
{stats.referrals?.total ?? 0}
|
||||||
<div className="ios26-panel" style={{ padding: 16 }}>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
|
</div>
|
||||||
Заработано
|
<div className="ios26-panel" style={{ padding: 16 }}>
|
||||||
</div>
|
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
|
||||||
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
|
Заработано
|
||||||
{formatCurrency(stats.earnings?.total ?? stats.bonus_account?.balance ?? 0)}
|
</div>
|
||||||
</div>
|
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
|
||||||
</div>
|
{formatCurrency(stats.earnings?.total ?? stats.bonus_account?.balance ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
{/* Список приглашённых рефералов */}
|
)}
|
||||||
{referralsList && (referralsList.direct.length > 0 || referralsList.indirect.length > 0) && (
|
|
||||||
<div>
|
{/* Список приглашённых рефералов */}
|
||||||
<div
|
{referralsList && (referralsList.direct.length > 0 || referralsList.indirect.length > 0) && (
|
||||||
style={{
|
<div>
|
||||||
fontSize: 11,
|
<div
|
||||||
fontWeight: 600,
|
style={{
|
||||||
letterSpacing: '0.05em',
|
fontSize: 11,
|
||||||
color: 'var(--md-sys-color-on-surface-variant)',
|
fontWeight: 600,
|
||||||
marginBottom: 8,
|
letterSpacing: '0.05em',
|
||||||
}}
|
color: 'var(--md-sys-color-on-surface-variant)',
|
||||||
>
|
marginBottom: 8,
|
||||||
ПРИГЛАШЁННЫЕ
|
}}
|
||||||
</div>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
ПРИГЛАШЁННЫЕ
|
||||||
{referralsList.direct.length > 0 && (
|
</div>
|
||||||
<div>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
|
{referralsList.direct.length > 0 && (
|
||||||
Прямые рефералы ({referralsList.direct.length})
|
<div>
|
||||||
</div>
|
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
|
||||||
<ul style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
|
Прямые рефералы ({referralsList.direct.length})
|
||||||
{referralsList.direct.map((r: MyReferralItem, i: number) => (
|
</div>
|
||||||
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}>
|
<ul className="referral-list" style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
|
||||||
{r.email} — {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
|
{referralsList.direct.map((r: MyReferralItem, i: number) => (
|
||||||
</li>
|
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)', wordBreak: 'break-word' }}>
|
||||||
))}
|
{r.email} — {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
))}
|
||||||
)}
|
</ul>
|
||||||
{referralsList.indirect.length > 0 && (
|
</div>
|
||||||
<div>
|
)}
|
||||||
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
|
{referralsList.indirect.length > 0 && (
|
||||||
Рефералы ваших рефералов ({referralsList.indirect.length})
|
<div>
|
||||||
</div>
|
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
|
||||||
<ul style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
|
Рефералы ваших рефералов ({referralsList.indirect.length})
|
||||||
{referralsList.indirect.map((r: MyReferralItem, i: number) => (
|
</div>
|
||||||
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}>
|
<ul className="referral-list" style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
|
||||||
{r.email} — {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
|
{referralsList.indirect.map((r: MyReferralItem, i: number) => (
|
||||||
</li>
|
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)', wordBreak: 'break-word' }}>
|
||||||
))}
|
{r.email} — {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
|
||||||
</ul>
|
</li>
|
||||||
</div>
|
))}
|
||||||
)}
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
{referralsList && referralsList.direct.length === 0 && referralsList.indirect.length === 0 && (
|
</div>
|
||||||
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
)}
|
||||||
Пока никого нет. Поделитесь реферальной ссылкой — когда кто-то зарегистрируется по ней, он появится здесь и вы получите уведомление.
|
{referralsList && referralsList.direct.length === 0 && referralsList.indirect.length === 0 && (
|
||||||
</p>
|
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
|
||||||
)}
|
Пока никого нет. Поделитесь реферальной ссылкой — когда кто-то зарегистрируется по ней, он появится здесь и вы получите уведомление.
|
||||||
</div>
|
</p>
|
||||||
);
|
)}
|
||||||
}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export const MOBILE_BREAKPOINT = 767;
|
||||||
|
|
||||||
|
export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT) {
|
||||||
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
||||||
|
setIsMobile(mq.matches);
|
||||||
|
const listener = () => setIsMobile(mq.matches);
|
||||||
|
mq.addEventListener('change', listener);
|
||||||
|
return () => mq.removeEventListener('change', listener);
|
||||||
|
}, [breakpoint]);
|
||||||
|
return isMobile;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="512" height="512" viewBox="0 8 130 130" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect y="8" width="130" height="130" rx="20" fill="#7444FD"/>
|
||||||
|
<path d="M31.8908 90.7V38H46.1708V87.555C46.1708 91.8617 47.2192 95.7717 49.3158 99.285C51.4125 102.798 54.2175 105.603 57.7308 107.7C61.3008 109.74 65.2108 110.76 69.4608 110.76C73.7675 110.76 77.6492 109.74 81.1058 107.7C84.6192 105.603 87.4242 102.798 89.5208 99.285C91.6175 95.7717 92.6658 91.8617 92.6658 87.555V38H106.946L107.031 123H92.7508L92.6658 112.205C89.6625 116.172 85.8658 119.345 81.2758 121.725C76.6858 124.048 71.7275 125.21 66.4008 125.21C60.0542 125.21 54.2458 123.68 48.9758 120.62C43.7625 117.503 39.5975 113.338 36.4808 108.125C33.4208 102.912 31.8908 97.1033 31.8908 90.7Z" fill="white"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 782 B |
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"name": "Uchill Platform",
|
||||||
|
"short_name": "Uchill",
|
||||||
|
"description": "Образовательная платформа",
|
||||||
|
"start_url": "/dashboard",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "any",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#7444FD",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icon.svg",
|
||||||
|
"sizes": "any",
|
||||||
|
"type": "image/svg+xml",
|
||||||
|
"purpose": "any"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue