mobile
Deploy to Production / deploy-production (push) Successful in 26s Details

This commit is contained in:
root 2026-02-18 01:15:06 +03:00
parent b4b99491ae
commit d4c4dbb087
37 changed files with 11862 additions and 11180 deletions

View File

@ -99,9 +99,19 @@ class UpdateLastActivityMiddleware:
cache.set(cache_key, now, timeout=self.UPDATE_INTERVAL * 2)
# Обновляем объект пользователя в запросе для текущего запроса
# Это позволяет использовать обновленное значение в текущем запросе
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:
# Логируем ошибку, но не прерываем выполнение запроса
logger.error(f"Ошибка при обновлении last_activity для пользователя {user.id}: {e}", exc_info=True)

View File

@ -309,9 +309,37 @@ class User(AbstractUser):
def __str__(self):
return f"{self.get_full_name()} ({self.email})"
def _generate_universal_code(self):
"""Генерация уникального 8-символьного кода (цифры + латинские буквы AZ)."""
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):
if 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)
@ -364,36 +392,8 @@ class Mentor(User):
def can_access_admin(self):
"""Может ли пользователь получить доступ к админ-панели."""
return self.is_staff or self.is_superuser or self.role == 'admin'
def _generate_universal_code(self):
"""Генерация уникального 8-символьного кода (цифры + латинские буквы AZ)."""
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):
"""Переопределение 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)
# Мы удалили Mentor.save и _generate_universal_code, так как они теперь в User
class Client(models.Model):

View File

@ -1505,6 +1505,27 @@ class InvitationViewSet(viewsets.ViewSet):
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.save(update_fields=['login_token'])
@ -1538,6 +1559,8 @@ class InvitationViewSet(viewsets.ViewSet):
from rest_framework_simplejwt.tokens import RefreshToken
refresh = RefreshToken.for_user(student_user)
# Обновляем из БД, чтобы в ответе был актуальный universal_code
student_user.refresh_from_db()
return Response({
'refresh': str(refresh),
'access': str(refresh.access_token),

File diff suppressed because it is too large Load Diff

View File

@ -391,10 +391,10 @@ REST_FRAMEWORK = {
'rest_framework.throttling.UserRateThrottle',
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour', # Для неавторизованных пользователей
'user': '1000/hour', # Для авторизованных пользователей
'anon': '200/hour', # Для неавторизованных пользователей
'user': '5000/hour', # Для авторизованных пользователей
'burst': '60/minute', # Для критичных endpoints (login, register)
'upload': '20/hour', # Для загрузки файлов
'upload': '60/hour', # Для загрузки файлов
},
}

View File

@ -84,28 +84,41 @@ RUN mkdir -p public
ENV NODE_ENV=production
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
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Копируем собранное приложение (standalone mode)
COPY --from=production-build /app/.next/standalone ./
COPY --from=production-build /app/.next/static ./.next/static
COPY --from=production-build /app/public ./public
# В Next.js standalone структура: /app/.next/standalone/ содержит server.js, .next/server/, node_modules/
# Копируем всё содержимое standalone в корень /app
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 adduser --system --uid 1001 nextjs
RUN chown -R nextjs:nodejs /app
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs
USER nextjs
# Открываем порт
EXPOSE 3000
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# Запускаем production server
CMD ["node", "server.js"]

View File

@ -1,38 +1,38 @@
/**
* API для подписок и оплаты
*/
import apiClient from '@/lib/api-client';
export interface Subscription {
id: number;
plan: { id: number; name: string };
start_date: string;
end_date: string;
student_count?: number;
}
export async function getActiveSubscription(): Promise<Subscription | null> {
try {
const response = await apiClient.get<any>('/subscriptions/subscriptions/active/');
return response.data;
} catch {
return null;
}
}
export interface ActivateFreeParams {
plan_id: number;
duration_days?: number;
student_count?: number;
}
/** Активировать бесплатный тариф (цена 0) без создания платежа */
export async function activateFreeSubscription(params: ActivateFreeParams): Promise<{ success: boolean; subscription: Subscription }> {
const url = '/subscriptions/subscriptions/activate_free/';
if (typeof window !== 'undefined') {
console.log('[API] POST', url, params);
}
const response = await apiClient.post<{ success: boolean; subscription: Subscription }>(url, params);
return response.data;
}
/**
* API для подписок и оплаты
*/
import apiClient from '@/lib/api-client';
export interface Subscription {
id: number;
plan: { id: number; name: string };
start_date: string;
end_date: string;
student_count?: number;
}
export async function getActiveSubscription(): Promise<Subscription | null> {
try {
const response = await apiClient.get<any>('/subscriptions/subscriptions/active/');
return response.data;
} catch {
return null;
}
}
export interface ActivateFreeParams {
plan_id: number;
duration_days?: number;
student_count?: number;
}
/** Активировать бесплатный тариф (цена 0) без создания платежа */
export async function activateFreeSubscription(params: ActivateFreeParams): Promise<{ success: boolean; subscription: Subscription }> {
const url = '/subscriptions/subscriptions/activate_free/';
if (typeof window !== 'undefined') {
console.log('[API] POST', url, params);
}
const response = await apiClient.post<{ success: boolean; subscription: Subscription }>(url, params);
return response.data;
}

View File

@ -8,6 +8,7 @@ export default function AuthLayout({
return (
<AuthRedirect>
<div
className="auth-layout"
data-no-nav
style={{
minHeight: '100vh',
@ -17,6 +18,7 @@ export default function AuthLayout({
>
{/* Левая колонка — пустая, фон как у body */}
<div
className="auth-layout-logo"
style={{
minHeight: '100vh',
display: 'flex',
@ -38,6 +40,7 @@ export default function AuthLayout({
{/* Правая колонка — форма на белом фоне */}
<div
className="auth-layout-form"
style={{
minHeight: '100vh',
background: '#fff',

View File

@ -234,6 +234,7 @@ export default function RegisterPage() {
<form onSubmit={handleSubmit}>
<div
className="auth-name-grid"
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',

View File

@ -1,236 +1,270 @@
'use client';
import React from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Box, Typography } from '@mui/material';
import { getConversations, getChatById } from '@/api/chat';
import type { Chat } from '@/api/chat';
import { ChatList } from '@/components/chat/ChatList';
import { ChatWindow } from '@/components/chat/ChatWindow';
import { usePresenceWebSocket } from '@/hooks/usePresenceWebSocket';
import { useAuth } from '@/contexts/AuthContext';
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
export default function ChatPage() {
const { user } = useAuth();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const uuidFromUrl = searchParams.get('uuid');
const [loading, setLoading] = React.useState(true);
const [chats, setChats] = React.useState<Chat[]>([]);
const [selected, setSelected] = React.useState<Chat | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [hasMore, setHasMore] = React.useState(false);
const [page, setPage] = React.useState(1);
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;
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
return () => {
document.documentElement.style.overflow = prevHtml;
document.body.style.overflow = prevBody;
};
}, []);
const normalizeChat = React.useCallback((c: any) => {
const otherName =
c?.other_participant?.full_name ||
[c?.other_participant?.first_name, c?.other_participant?.last_name].filter(Boolean).join(' ') ||
c?.participant_name ||
'Чат';
const avatarUrl = c?.other_participant?.avatar_url || c?.other_participant?.avatar || null;
const otherId = c?.other_participant?.id ?? null;
const otherOnline = !!c?.other_participant?.is_online;
const otherLast = c?.other_participant?.last_activity ?? null;
const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || '';
const unread = c?.my_participant?.unread_count ?? c?.unread_count ?? 0;
return {
id: c.id,
uuid: c.uuid,
participant_name: otherName,
avatar_url: avatarUrl,
other_user_id: otherId,
other_is_online: otherOnline,
other_last_activity: otherLast,
last_message: lastText,
unread_count: unread,
created_at: c.created_at,
};
}, []);
React.useEffect(() => {
(async () => {
try {
setLoading(true);
setError(null);
const resp = await getConversations({ page: 1, page_size: 30 });
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
setChats(normalized as any);
setHasMore(!!(resp as any).next);
setPage(1);
} catch (e: any) {
console.error('[ChatPage] Ошибка загрузки чатов:', e);
const msg =
e?.response?.data?.detail ||
e?.response?.data?.error ||
e?.message ||
'Не удалось загрузить чаты';
setError(String(msg));
} finally {
setLoading(false);
}
})();
}, [normalizeChat]);
const restoredForUuidRef = React.useRef<string | null>(null);
// Восстановить выбранный чат из URL после загрузки списка (или по uuid)
React.useEffect(() => {
if (loading || error || !uuidFromUrl) return;
if (restoredForUuidRef.current === uuidFromUrl) return;
const found = chats.find((c) => (c as any).uuid === uuidFromUrl);
if (found) {
setSelected(found as Chat);
restoredForUuidRef.current = uuidFromUrl;
return;
}
(async () => {
try {
const c = await getChatById(uuidFromUrl);
const normalized = normalizeChat(c) as any;
setSelected(normalized as Chat);
restoredForUuidRef.current = uuidFromUrl;
} catch (e: any) {
console.warn('[ChatPage] Чат по uuid из URL не найден:', uuidFromUrl, e);
restoredForUuidRef.current = null;
router.replace(pathname ?? '/chat');
}
})();
}, [loading, error, uuidFromUrl, chats, normalizeChat, router, pathname]);
React.useEffect(() => {
if (!uuidFromUrl) restoredForUuidRef.current = null;
}, [uuidFromUrl]);
const handleSelectChat = React.useCallback(
(c: Chat) => {
setSelected(c);
const u = (c as any).uuid;
if (u) {
const base = pathname ?? '/chat';
router.replace(`${base}?uuid=${encodeURIComponent(u)}`);
}
},
[router, pathname]
);
const loadMore = React.useCallback(async () => {
if (loadingMore || !hasMore) return;
try {
setLoadingMore(true);
const next = page + 1;
const resp = await getConversations({ page: next, page_size: 30 });
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
setChats((prev) => [...prev, ...(normalized as any)]);
setHasMore(!!(resp as any).next);
setPage(next);
} catch (e: any) {
console.error('[ChatPage] Ошибка загрузки чатов:', e);
} finally {
setLoadingMore(false);
}
}, [page, hasMore, loadingMore, normalizeChat]);
const refreshChatListUnread = React.useCallback(async () => {
try {
const resp = await getConversations({ page: 1, page_size: 30 });
const fresh = (resp.results || []).map((c: any) => normalizeChat(c)) as Chat[];
const freshByUuid = new Map(fresh.map((c: any) => [(c as any).uuid, c]));
setChats((prev) =>
prev.map((c: any) => {
const updated = freshByUuid.get(c.uuid);
return updated ? (updated as Chat) : c;
})
);
await refreshNavBadges?.();
} catch {
// ignore
}
}, [normalizeChat, refreshNavBadges]);
return (
<div className="ios26-dashboard ios26-chat-page" style={{ padding: '16px' }}>
<Box
className="ios26-chat-layout"
sx={{
display: 'grid',
gridTemplateColumns: '320px 1fr',
gap: 'var(--ios26-spacing)',
alignItems: 'stretch',
height: 'calc(90vh - 32px)',
maxHeight: 'calc(90vh - 32px)',
overflow: 'hidden',
}}
>
{loading ? (
<Box
className="ios-glass-panel"
sx={{
borderRadius: '20px',
p: 2,
display: 'flex',
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>Загрузка</Typography>
</Box>
) : error ? (
<Box
className="ios-glass-panel"
sx={{
borderRadius: '20px',
p: 2,
display: 'flex',
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}
/>
)}
<ChatWindow
chat={selected}
currentUserId={user?.id ?? null}
onMessagesMarkedAsRead={refreshChatListUnread}
/>
</Box>
</div>
);
}
'use client';
import React from 'react';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
import { Box, Typography } from '@mui/material';
import { getConversations, getChatById } from '@/api/chat';
import type { Chat } from '@/api/chat';
import { ChatList } from '@/components/chat/ChatList';
import { ChatWindow } from '@/components/chat/ChatWindow';
import { usePresenceWebSocket } from '@/hooks/usePresenceWebSocket';
import { useAuth } from '@/contexts/AuthContext';
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
import { useIsMobile } from '@/hooks/useIsMobile';
export default function ChatPage() {
const { user } = useAuth();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const uuidFromUrl = searchParams.get('uuid');
const isMobile = useIsMobile();
const [loading, setLoading] = React.useState(true);
const [chats, setChats] = React.useState<Chat[]>([]);
const [selected, setSelected] = React.useState<Chat | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [hasMore, setHasMore] = React.useState(false);
const [page, setPage] = React.useState(1);
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;
document.documentElement.style.overflow = 'hidden';
document.body.style.overflow = 'hidden';
return () => {
document.documentElement.style.overflow = prevHtml;
document.body.style.overflow = prevBody;
};
}, []);
const normalizeChat = React.useCallback((c: any) => {
const otherName =
c?.other_participant?.full_name ||
[c?.other_participant?.first_name, c?.other_participant?.last_name].filter(Boolean).join(' ') ||
c?.participant_name ||
'Чат';
const avatarUrl = c?.other_participant?.avatar_url || c?.other_participant?.avatar || null;
const otherId = c?.other_participant?.id ?? null;
const otherOnline = !!c?.other_participant?.is_online;
const otherLast = c?.other_participant?.last_activity ?? null;
const lastText = c?.last_message?.content || c?.last_message?.text || c?.last_message || '';
const unread = c?.my_participant?.unread_count ?? c?.unread_count ?? 0;
return {
id: c.id,
uuid: c.uuid,
participant_name: otherName,
avatar_url: avatarUrl,
other_user_id: otherId,
other_is_online: otherOnline,
other_last_activity: otherLast,
last_message: lastText,
unread_count: unread,
created_at: c.created_at,
};
}, []);
React.useEffect(() => {
(async () => {
try {
setLoading(true);
setError(null);
const resp = await getConversations({ page: 1, page_size: 30 });
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
setChats(normalized as any);
setHasMore(!!(resp as any).next);
setPage(1);
} catch (e: any) {
console.error('[ChatPage] Ошибка загрузки чатов:', e);
const msg =
e?.response?.data?.detail ||
e?.response?.data?.error ||
e?.message ||
'Не удалось загрузить чаты';
setError(String(msg));
} finally {
setLoading(false);
}
})();
}, [normalizeChat]);
const restoredForUuidRef = React.useRef<string | null>(null);
// Восстановить выбранный чат из URL после загрузки списка (или по uuid)
React.useEffect(() => {
if (loading || error || !uuidFromUrl) return;
if (restoredForUuidRef.current === uuidFromUrl) return;
const found = chats.find((c) => (c as any).uuid === uuidFromUrl);
if (found) {
setSelected(found as Chat);
restoredForUuidRef.current = uuidFromUrl;
return;
}
(async () => {
try {
const c = await getChatById(uuidFromUrl);
const normalized = normalizeChat(c) as any;
setSelected(normalized as Chat);
restoredForUuidRef.current = uuidFromUrl;
} catch (e: any) {
console.warn('[ChatPage] Чат по uuid из URL не найден:', uuidFromUrl, e);
restoredForUuidRef.current = null;
router.replace(pathname ?? '/chat');
}
})();
}, [loading, error, uuidFromUrl, chats, normalizeChat, router, pathname]);
React.useEffect(() => {
if (!uuidFromUrl) restoredForUuidRef.current = null;
}, [uuidFromUrl]);
const handleSelectChat = React.useCallback(
(c: Chat) => {
setSelected(c);
const u = (c as any).uuid;
if (u) {
const base = pathname ?? '/chat';
router.replace(`${base}?uuid=${encodeURIComponent(u)}`);
}
},
[router, pathname]
);
const loadMore = React.useCallback(async () => {
if (loadingMore || !hasMore) return;
try {
setLoadingMore(true);
const next = page + 1;
const resp = await getConversations({ page: next, page_size: 30 });
const normalized = (resp.results || []).map((c: any) => normalizeChat(c));
setChats((prev) => [...prev, ...(normalized as any)]);
setHasMore(!!(resp as any).next);
setPage(next);
} catch (e: any) {
console.error('[ChatPage] Ошибка загрузки чатов:', e);
} finally {
setLoadingMore(false);
}
}, [page, hasMore, loadingMore, normalizeChat]);
const refreshChatListUnread = React.useCallback(async () => {
try {
const resp = await getConversations({ page: 1, page_size: 30 });
const fresh = (resp.results || []).map((c: any) => normalizeChat(c)) as Chat[];
const freshByUuid = new Map(fresh.map((c: any) => [(c as any).uuid, c]));
setChats((prev) =>
prev.map((c: any) => {
const updated = freshByUuid.get(c.uuid);
return updated ? (updated as Chat) : c;
})
);
await refreshNavBadges?.();
} catch {
// ignore
}
}, [normalizeChat, refreshNavBadges]);
const handleBackToList = React.useCallback(() => {
setSelected(null);
router.replace(pathname ?? '/chat');
}, [router, pathname]);
// Mobile: show only list or only chat
const mobileShowChat = isMobile && selected != null;
// Hide bottom navigation when a chat is open on mobile
React.useEffect(() => {
if (mobileShowChat) {
document.documentElement.classList.add('mobile-chat-open');
} else {
document.documentElement.classList.remove('mobile-chat-open');
}
return () => {
document.documentElement.classList.remove('mobile-chat-open');
};
}, [mobileShowChat]);
return (
<div className="ios26-dashboard ios26-chat-page" style={{ padding: isMobile ? '8px' : '16px' }}>
<Box
className="ios26-chat-layout"
sx={{
display: isMobile ? 'flex' : 'grid',
gridTemplateColumns: isMobile ? undefined : '320px 1fr',
flexDirection: isMobile ? 'column' : undefined,
gap: 'var(--ios26-spacing)',
alignItems: 'stretch',
height: isMobile ? '100%' : 'calc(90vh - 32px)',
maxHeight: isMobile ? undefined : 'calc(90vh - 32px)',
overflow: 'hidden',
}}
>
{/* Chat list: hidden on mobile when a chat is selected */}
{!mobileShowChat && (
<>
{loading ? (
<Box
className="ios-glass-panel"
sx={{
borderRadius: '20px',
p: 2,
display: 'flex',
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>Загрузка</Typography>
</Box>
) : error ? (
<Box
className="ios-glass-panel"
sx={{
borderRadius: '20px',
p: 2,
display: 'flex',
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>
);
}

View File

@ -1,19 +1,7 @@
'use client';
import { useEffect, useState, useCallback, Suspense } from 'react';
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 { useIsMobile } from '@/hooks/useIsMobile';
import { useRouter, usePathname } from 'next/navigation';
import { BottomNavigationBar } from '@/components/navigation/BottomNavigationBar';
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 });
if (!loading && !user && !token) {
console.log('[ProtectedLayout] Redirecting to login');
router.push('/login');
if (!loading && !user) {
console.log('[ProtectedLayout] No user found, redirecting to login');
router.replace('/login');
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user, loading]);
@ -114,7 +102,14 @@ export default function ProtectedLayout({
}
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: ждём результат проверки подписки, чтобы не показывать контент перед редиректом
@ -153,24 +148,13 @@ export default function ProtectedLayout({
return (
<NavBadgesProvider refreshNavBadges={refreshNavBadges}>
<SelectedChildProvider>
<div
className="protected-layout-root"
style={{
display: 'flex',
flexDirection: 'column',
minHeight: '100vh',
height: '100vh',
}}
>
<div className="protected-layout-root">
{!isFullWidthPage && <TopNavigationBar user={user} />}
<main
className="protected-main"
data-no-nav={isLiveKit ? true : undefined}
data-full-width={isFullWidthPage ? true : undefined}
style={{
flex: 1,
minHeight: 0,
overflow: 'auto',
padding: isFullWidthPage ? '0' : '16px',
maxWidth: isFullWidthPage ? '100%' : '1200px',
margin: isFullWidthPage ? '0' : '0 auto',

File diff suppressed because it is too large Load Diff

View File

@ -1,426 +1,426 @@
'use client';
import { useEffect, useState, useMemo } from 'react';
import { getLessons, type Lesson } from '@/api/schedule';
import { getHomeworkSubmissionsBySubject, type HomeworkSubmission } from '@/api/homework';
import { format, subMonths, startOfDay, endOfDay, addDays } from 'date-fns';
import dayjs from 'dayjs';
import dynamic from 'next/dynamic';
import { useSelectedChild } from '@/contexts/SelectedChildContext';
import { DashboardLayout, Panel, SectionHeader } from '@/components/dashboard/ui';
import { DateRangePicker } from '@/components/common/DateRangePicker';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
const Chart = dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
ssr: false,
loading: () => (
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>
<LoadingSpinner size="medium" />
</div>
),
});
const CHART_COLORS = ['#6750A4', '#7D5260'];
const defaultRange = {
start_date: dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
end_date: dayjs().format('YYYY-MM-DD'),
};
function getSubjectFromLesson(lesson: Lesson): string {
if (typeof lesson.subject === 'string' && lesson.subject?.trim()) return lesson.subject.trim();
return '';
}
/** Все даты в диапазоне [startStr, endStr] включительно (формат YYYY-MM-DD) */
function getDatesInRange(startStr: string, endStr: string): string[] {
const dates: string[] = [];
let d = new Date(startStr);
const end = new Date(endStr);
while (d <= end) {
dates.push(format(d, 'yyyy-MM-dd'));
d = addDays(d, 1);
}
return dates;
}
export default function MyProgressPage() {
const { selectedChild } = useSelectedChild();
const [dateRangeValue, setDateRangeValue] = useState<{ start_date: string; end_date: string }>(() => defaultRange);
const [selectedSubject, setSelectedSubject] = useState<string>('');
const dateRange = useMemo(() => ({
start: startOfDay(new Date(dateRangeValue.start_date)),
end: endOfDay(new Date(dateRangeValue.end_date)),
}), [dateRangeValue.start_date, dateRangeValue.end_date]);
const startStr = format(dateRange.start, 'yyyy-MM-dd');
const endStr = format(dateRange.end, 'yyyy-MM-dd');
const [subjectsFromLessons, setSubjectsFromLessons] = useState<string[]>([]);
const [subjectsLoading, setSubjectsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setSubjectsLoading(true);
getLessons({
start_date: format(subMonths(new Date(), 24), 'yyyy-MM-dd'),
end_date: format(new Date(), 'yyyy-MM-dd'),
...(selectedChild?.id && { child_id: selectedChild.id }),
})
.then((res) => {
if (cancelled) return;
const set = new Set<string>();
(res.results || []).forEach((l: Lesson) => {
const sub = getSubjectFromLesson(l);
if (sub) set.add(sub);
});
const list = Array.from(set).sort();
setSubjectsFromLessons(list);
if (list.length > 0 && !selectedSubject) setSelectedSubject(list[0]);
})
.catch(() => { if (!cancelled) setSubjectsFromLessons([]); })
.finally(() => { if (!cancelled) setSubjectsLoading(false); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (subjectsFromLessons.length > 0 && !selectedSubject) setSelectedSubject(subjectsFromLessons[0]);
}, [subjectsFromLessons, selectedSubject]);
const [lessons, setLessons] = useState<Lesson[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(false);
useEffect(() => {
if (!startStr || !endStr) return;
let cancelled = false;
setLessonsLoading(true);
getLessons({
start_date: startStr,
end_date: endStr,
...(selectedChild?.id && { child_id: selectedChild.id }),
})
.then((res) => {
if (cancelled) return;
const list = (res.results || []).filter((l: Lesson) => {
const sub = getSubjectFromLesson(l);
return selectedSubject ? sub === selectedSubject : true;
});
list.sort((a: Lesson, b: Lesson) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
setLessons(list);
})
.catch(() => { if (!cancelled) setLessons([]); })
.finally(() => { if (!cancelled) setLessonsLoading(false); });
return () => { cancelled = true; };
}, [startStr, endStr, selectedSubject, selectedChild?.id]);
const [homeworkSubmissions, setHomeworkSubmissions] = useState<HomeworkSubmission[]>([]);
const [homeworkLoading, setHomeworkLoading] = useState(false);
useEffect(() => {
if (!selectedSubject) {
setHomeworkSubmissions([]);
return;
}
let cancelled = false;
setHomeworkLoading(true);
getHomeworkSubmissionsBySubject({
subject: selectedSubject,
start_date: startStr,
end_date: endStr,
...(selectedChild?.id && { child_id: selectedChild.id }),
})
.then((res) => {
if (cancelled) return;
setHomeworkSubmissions(res.results || []);
})
.catch(() => { if (!cancelled) setHomeworkSubmissions([]); })
.finally(() => { if (!cancelled) setHomeworkLoading(false); });
return () => { cancelled = true; };
}, [selectedSubject, startStr, endStr, selectedChild?.id]);
const periodStats = useMemo(() => {
const completed = lessons.filter((l) => l.status === 'completed').length;
const total = lessons.length;
const cancelled = lessons.filter((l) => l.status === 'cancelled').length;
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));
let sum = 0;
let count = 0;
withGrades.forEach((l) => {
if (l.mentor_grade != null) { sum += l.mentor_grade; count++; }
if (l.school_grade != null) { sum += l.school_grade; count++; }
});
const avgGrade = count > 0 ? Math.round((sum / count) * 10) / 10 : 0;
const hwGraded = homeworkSubmissions.filter((s) => s.score != null && s.checked_at).length;
return {
completedLessons: completed,
totalLessons: total,
attendanceRate,
cancelled,
avgGrade,
hwGraded,
};
}, [lessons, homeworkSubmissions]);
const gradesChart = useMemo(() => {
const allDates = getDatesInRange(startStr, endStr);
const categories = allDates.map((d) => {
const [, m, day] = d.split('-');
return `${day}.${m}`;
});
const byDate: Record<string, { mentor: number | null; school: number | null }> = {};
lessons
.filter((l) => l.status === 'completed' && (l.mentor_grade != null || l.school_grade != null))
.forEach((l) => {
const key = l.start_time?.slice(0, 10) || '';
if (!key) return;
byDate[key] = { mentor: l.mentor_grade ?? null, school: l.school_grade ?? null };
});
const mentorGrades = allDates.map((d) => byDate[d]?.mentor ?? null);
const schoolGrades = allDates.map((d) => byDate[d]?.school ?? null);
return {
series: [
{ name: 'Оценка репетитора', data: mentorGrades },
{ name: 'Оценка в школе', data: schoolGrades },
],
categories,
};
}, [lessons, startStr, endStr]);
const homeworkChart = useMemo(() => {
const allDates = getDatesInRange(startStr, endStr);
const categories = allDates.map((d) => {
const [, m, day] = d.split('-');
return `${day}.${m}`;
});
const byDate: Record<string, number | null> = {};
homeworkSubmissions
.filter((s) => s.checked_at && s.score != null)
.forEach((s) => {
const key = format(new Date(s.checked_at!), 'yyyy-MM-dd');
byDate[key] = s.score ?? null;
});
const scores = allDates.map((d) => byDate[d] ?? null);
return {
series: [{ name: 'Оценка за ДЗ', data: scores }],
categories,
};
}, [homeworkSubmissions, startStr, endStr]);
// Посещаемость: по датам — сколько занятий было проведено; ось X = все даты периода
const attendanceChart = useMemo(() => {
const allDates = getDatesInRange(startStr, endStr);
const categories = allDates.map((d) => {
const [, m, day] = d.split('-');
return `${day}.${m}`;
});
const byDate: Record<string, number> = {};
lessons.forEach((l) => {
if (l.status !== 'completed') return;
const key = l.start_time?.slice(0, 10) || '';
if (!key) return;
byDate[key] = (byDate[key] ?? 0) + 1;
});
const data = allDates.map((d) => byDate[d] ?? 0);
return {
series: [{ name: 'Занятия проведены', data }],
categories,
};
}, [lessons, startStr, endStr]);
const subjects = subjectsFromLessons;
const loading = lessonsLoading && lessons.length === 0;
const chartOptionsBase = useMemo(
() => ({
chart: {
toolbar: {
show: true,
tools: {
download: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true,
},
},
zoom: { enabled: true, type: 'x' as const, allowMouseWheelZoom: true },
pan: { enabled: true, type: 'x' as const },
selection: { enabled: true, type: 'x' as const },
},
stroke: { curve: 'smooth' as const, width: 2 },
colors: CHART_COLORS,
dataLabels: { enabled: false },
xaxis: {
axisBorder: { show: false },
axisTicks: { show: false },
labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } },
},
yaxis: {
labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } },
},
legend: {
position: 'bottom' as const,
horizontalAlign: 'center' as const,
labels: { colors: 'var(--md-sys-color-on-surface-variant)' },
},
grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 },
}),
[],
);
const selectStyle = {
padding: '8px 12px',
borderRadius: 12,
border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
background: 'var(--md-sys-color-surface-container-low)',
color: 'var(--md-sys-color-on-surface)',
minWidth: 180,
fontSize: 14,
cursor: 'pointer' as const,
outline: 'none' as const,
};
return (
<div style={{ width: '100%', minHeight: '100vh' }}>
<DashboardLayout className="ios26-dashboard-grid">
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
<Panel padding="md">
<SectionHeader
title="Прогресс за период"
trailing={
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center' }}>
<select
value={selectedSubject}
onChange={(e) => setSelectedSubject(e.target.value)}
disabled={!subjects.length}
style={{
...selectStyle,
opacity: subjects.length ? 1 : 0.7,
}}
>
{subjects.length === 0 ? (
<option value="">Нет предметов</option>
) : (
subjects.map((s) => (
<option key={s} value={s}>{s}</option>
))
)}
</select>
<DateRangePicker
value={dateRangeValue}
onChange={(v) => setDateRangeValue({ start_date: v.start_date, end_date: v.end_date })}
disabled={subjectsLoading}
/>
</div>
}
/>
{loading ? (
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<LoadingSpinner size="medium" />
</div>
) : (
<div className="ios26-stat-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
<div className="ios26-stat-tile">
<div className="ios26-stat-label">Занятий проведено</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>
<div className="ios26-stat-tile">
<div className="ios26-stat-label">Посещаемость</div>
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.attendanceRate}%</div>
</div>
<div className="ios26-stat-tile">
<div className="ios26-stat-label">Средняя оценка</div>
<div className="ios26-stat-value">{periodStats.avgGrade || '—'}</div>
</div>
<div className="ios26-stat-tile">
<div className="ios26-stat-label">ДЗ с оценкой</div>
<div className="ios26-stat-value">{periodStats.hwGraded}</div>
</div>
</div>
)}
</Panel>
{/* Ячейка 2: Успеваемость (оценки репетитора и школы) */}
<Panel padding="md">
<SectionHeader title="Успеваемость (репетитор и школа)" />
{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>
) : (
<Chart
type="line"
height={260}
series={gradesChart.series}
options={{
...chartOptionsBase,
xaxis: { ...chartOptionsBase.xaxis, categories: gradesChart.categories },
yaxis: { ...chartOptionsBase.yaxis, min: 1, max: 5, title: { text: 'Оценка (15)' } },
}}
/>
)}
</Panel>
{/* Ячейка 3: Успеваемость по ДЗ */}
<Panel padding="md">
<SectionHeader title="Успеваемость по ДЗ" />
{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>
) : (
<Chart
type="line"
height={260}
series={homeworkChart.series}
options={{
...chartOptionsBase,
colors: ['#6750A4'],
xaxis: { ...chartOptionsBase.xaxis, categories: homeworkChart.categories },
yaxis: { ...chartOptionsBase.yaxis, min: 0, max: 100, title: { text: 'Оценка' } },
}}
/>
)}
</Panel>
{/* Ячейка 4: Посещаемость — проведённые занятия по датам */}
<Panel padding="md">
<SectionHeader title="Посещаемость" />
{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>
) : (
<Chart
type="line"
height={260}
series={attendanceChart.series}
options={{
...chartOptionsBase,
colors: ['#6750A4'],
xaxis: { ...chartOptionsBase.xaxis, categories: attendanceChart.categories },
yaxis: {
...chartOptionsBase.yaxis,
title: { text: 'Занятий' },
min: 0,
tickAmount: 4,
labels: {
...(chartOptionsBase.yaxis?.labels ?? {}),
formatter: (val: number) => String(Math.round(val)),
},
},
}}
/>
)}
</Panel>
</DashboardLayout>
</div>
);
}
'use client';
import { useEffect, useState, useMemo } from 'react';
import { getLessons, type Lesson } from '@/api/schedule';
import { getHomeworkSubmissionsBySubject, type HomeworkSubmission } from '@/api/homework';
import { format, subMonths, startOfDay, endOfDay, addDays } from 'date-fns';
import dayjs from 'dayjs';
import dynamic from 'next/dynamic';
import { useSelectedChild } from '@/contexts/SelectedChildContext';
import { DashboardLayout, Panel, SectionHeader } from '@/components/dashboard/ui';
import { DateRangePicker } from '@/components/common/DateRangePicker';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
const Chart = dynamic(() => import('react-apexcharts').then((mod) => mod.default), {
ssr: false,
loading: () => (
<div style={{ height: 260, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--md-sys-color-on-surface-variant)' }}>
<LoadingSpinner size="medium" />
</div>
),
});
const CHART_COLORS = ['#6750A4', '#7D5260'];
const defaultRange = {
start_date: dayjs().subtract(7, 'day').format('YYYY-MM-DD'),
end_date: dayjs().format('YYYY-MM-DD'),
};
function getSubjectFromLesson(lesson: Lesson): string {
if (typeof lesson.subject === 'string' && lesson.subject?.trim()) return lesson.subject.trim();
return '';
}
/** Все даты в диапазоне [startStr, endStr] включительно (формат YYYY-MM-DD) */
function getDatesInRange(startStr: string, endStr: string): string[] {
const dates: string[] = [];
let d = new Date(startStr);
const end = new Date(endStr);
while (d <= end) {
dates.push(format(d, 'yyyy-MM-dd'));
d = addDays(d, 1);
}
return dates;
}
export default function MyProgressPage() {
const { selectedChild } = useSelectedChild();
const [dateRangeValue, setDateRangeValue] = useState<{ start_date: string; end_date: string }>(() => defaultRange);
const [selectedSubject, setSelectedSubject] = useState<string>('');
const dateRange = useMemo(() => ({
start: startOfDay(new Date(dateRangeValue.start_date)),
end: endOfDay(new Date(dateRangeValue.end_date)),
}), [dateRangeValue.start_date, dateRangeValue.end_date]);
const startStr = format(dateRange.start, 'yyyy-MM-dd');
const endStr = format(dateRange.end, 'yyyy-MM-dd');
const [subjectsFromLessons, setSubjectsFromLessons] = useState<string[]>([]);
const [subjectsLoading, setSubjectsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setSubjectsLoading(true);
getLessons({
start_date: format(subMonths(new Date(), 24), 'yyyy-MM-dd'),
end_date: format(new Date(), 'yyyy-MM-dd'),
...(selectedChild?.id && { child_id: selectedChild.id }),
})
.then((res) => {
if (cancelled) return;
const set = new Set<string>();
(res.results || []).forEach((l: Lesson) => {
const sub = getSubjectFromLesson(l);
if (sub) set.add(sub);
});
const list = Array.from(set).sort();
setSubjectsFromLessons(list);
if (list.length > 0 && !selectedSubject) setSelectedSubject(list[0]);
})
.catch(() => { if (!cancelled) setSubjectsFromLessons([]); })
.finally(() => { if (!cancelled) setSubjectsLoading(false); });
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (subjectsFromLessons.length > 0 && !selectedSubject) setSelectedSubject(subjectsFromLessons[0]);
}, [subjectsFromLessons, selectedSubject]);
const [lessons, setLessons] = useState<Lesson[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(false);
useEffect(() => {
if (!startStr || !endStr) return;
let cancelled = false;
setLessonsLoading(true);
getLessons({
start_date: startStr,
end_date: endStr,
...(selectedChild?.id && { child_id: selectedChild.id }),
})
.then((res) => {
if (cancelled) return;
const list = (res.results || []).filter((l: Lesson) => {
const sub = getSubjectFromLesson(l);
return selectedSubject ? sub === selectedSubject : true;
});
list.sort((a: Lesson, b: Lesson) => new Date(a.start_time).getTime() - new Date(b.start_time).getTime());
setLessons(list);
})
.catch(() => { if (!cancelled) setLessons([]); })
.finally(() => { if (!cancelled) setLessonsLoading(false); });
return () => { cancelled = true; };
}, [startStr, endStr, selectedSubject, selectedChild?.id]);
const [homeworkSubmissions, setHomeworkSubmissions] = useState<HomeworkSubmission[]>([]);
const [homeworkLoading, setHomeworkLoading] = useState(false);
useEffect(() => {
if (!selectedSubject) {
setHomeworkSubmissions([]);
return;
}
let cancelled = false;
setHomeworkLoading(true);
getHomeworkSubmissionsBySubject({
subject: selectedSubject,
start_date: startStr,
end_date: endStr,
...(selectedChild?.id && { child_id: selectedChild.id }),
})
.then((res) => {
if (cancelled) return;
setHomeworkSubmissions(res.results || []);
})
.catch(() => { if (!cancelled) setHomeworkSubmissions([]); })
.finally(() => { if (!cancelled) setHomeworkLoading(false); });
return () => { cancelled = true; };
}, [selectedSubject, startStr, endStr, selectedChild?.id]);
const periodStats = useMemo(() => {
const completed = lessons.filter((l) => l.status === 'completed').length;
const total = lessons.length;
const cancelled = lessons.filter((l) => l.status === 'cancelled').length;
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));
let sum = 0;
let count = 0;
withGrades.forEach((l) => {
if (l.mentor_grade != null) { sum += l.mentor_grade; count++; }
if (l.school_grade != null) { sum += l.school_grade; count++; }
});
const avgGrade = count > 0 ? Math.round((sum / count) * 10) / 10 : 0;
const hwGraded = homeworkSubmissions.filter((s) => s.score != null && s.checked_at).length;
return {
completedLessons: completed,
totalLessons: total,
attendanceRate,
cancelled,
avgGrade,
hwGraded,
};
}, [lessons, homeworkSubmissions]);
const gradesChart = useMemo(() => {
const allDates = getDatesInRange(startStr, endStr);
const categories = allDates.map((d) => {
const [, m, day] = d.split('-');
return `${day}.${m}`;
});
const byDate: Record<string, { mentor: number | null; school: number | null }> = {};
lessons
.filter((l) => l.status === 'completed' && (l.mentor_grade != null || l.school_grade != null))
.forEach((l) => {
const key = l.start_time?.slice(0, 10) || '';
if (!key) return;
byDate[key] = { mentor: l.mentor_grade ?? null, school: l.school_grade ?? null };
});
const mentorGrades = allDates.map((d) => byDate[d]?.mentor ?? null);
const schoolGrades = allDates.map((d) => byDate[d]?.school ?? null);
return {
series: [
{ name: 'Оценка репетитора', data: mentorGrades },
{ name: 'Оценка в школе', data: schoolGrades },
],
categories,
};
}, [lessons, startStr, endStr]);
const homeworkChart = useMemo(() => {
const allDates = getDatesInRange(startStr, endStr);
const categories = allDates.map((d) => {
const [, m, day] = d.split('-');
return `${day}.${m}`;
});
const byDate: Record<string, number | null> = {};
homeworkSubmissions
.filter((s) => s.checked_at && s.score != null)
.forEach((s) => {
const key = format(new Date(s.checked_at!), 'yyyy-MM-dd');
byDate[key] = s.score ?? null;
});
const scores = allDates.map((d) => byDate[d] ?? null);
return {
series: [{ name: 'Оценка за ДЗ', data: scores }],
categories,
};
}, [homeworkSubmissions, startStr, endStr]);
// Посещаемость: по датам — сколько занятий было проведено; ось X = все даты периода
const attendanceChart = useMemo(() => {
const allDates = getDatesInRange(startStr, endStr);
const categories = allDates.map((d) => {
const [, m, day] = d.split('-');
return `${day}.${m}`;
});
const byDate: Record<string, number> = {};
lessons.forEach((l) => {
if (l.status !== 'completed') return;
const key = l.start_time?.slice(0, 10) || '';
if (!key) return;
byDate[key] = (byDate[key] ?? 0) + 1;
});
const data = allDates.map((d) => byDate[d] ?? 0);
return {
series: [{ name: 'Занятия проведены', data }],
categories,
};
}, [lessons, startStr, endStr]);
const subjects = subjectsFromLessons;
const loading = lessonsLoading && lessons.length === 0;
const chartOptionsBase = useMemo(
() => ({
chart: {
toolbar: {
show: true,
tools: {
download: true,
zoom: true,
zoomin: true,
zoomout: true,
pan: true,
reset: true,
},
},
zoom: { enabled: true, type: 'x' as const, allowMouseWheelZoom: true },
pan: { enabled: true, type: 'x' as const },
selection: { enabled: true, type: 'x' as const },
},
stroke: { curve: 'smooth' as const, width: 2 },
colors: CHART_COLORS,
dataLabels: { enabled: false },
xaxis: {
axisBorder: { show: false },
axisTicks: { show: false },
labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } },
},
yaxis: {
labels: { style: { colors: 'var(--md-sys-color-on-surface-variant)', fontSize: '12px' } },
},
legend: {
position: 'bottom' as const,
horizontalAlign: 'center' as const,
labels: { colors: 'var(--md-sys-color-on-surface-variant)' },
},
grid: { borderColor: 'var(--ios26-list-divider)', strokeDashArray: 4 },
}),
[],
);
const selectStyle = {
padding: '8px 12px',
borderRadius: 12,
border: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
background: 'var(--md-sys-color-surface-container-low)',
color: 'var(--md-sys-color-on-surface)',
minWidth: 180,
fontSize: 14,
cursor: 'pointer' as const,
outline: 'none' as const,
};
return (
<div style={{ width: '100%' }}>
<DashboardLayout className="ios26-dashboard-grid">
{/* Ячейка 1: Общая статистика за период + выбор предмета и даты */}
<Panel padding="md">
<SectionHeader
title="Прогресс за период"
trailing={
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 10, alignItems: 'center' }}>
<select
value={selectedSubject}
onChange={(e) => setSelectedSubject(e.target.value)}
disabled={!subjects.length}
style={{
...selectStyle,
opacity: subjects.length ? 1 : 0.7,
}}
>
{subjects.length === 0 ? (
<option value="">Нет предметов</option>
) : (
subjects.map((s) => (
<option key={s} value={s}>{s}</option>
))
)}
</select>
<DateRangePicker
value={dateRangeValue}
onChange={(v) => setDateRangeValue({ start_date: v.start_date, end_date: v.end_date })}
disabled={subjectsLoading}
/>
</div>
}
/>
{loading ? (
<div style={{ minHeight: 200, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<LoadingSpinner size="medium" />
</div>
) : (
<div className="ios26-stat-grid my-progress-grid" style={{ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))' }}>
<div className="ios26-stat-tile">
<div className="ios26-stat-label">Занятий проведено</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>
<div className="ios26-stat-tile">
<div className="ios26-stat-label">Посещаемость</div>
<div className="ios26-stat-value ios26-stat-value--primary">{periodStats.attendanceRate}%</div>
</div>
<div className="ios26-stat-tile">
<div className="ios26-stat-label">Средняя оценка</div>
<div className="ios26-stat-value">{periodStats.avgGrade || '—'}</div>
</div>
<div className="ios26-stat-tile">
<div className="ios26-stat-label">ДЗ с оценкой</div>
<div className="ios26-stat-value">{periodStats.hwGraded}</div>
</div>
</div>
)}
</Panel>
{/* Ячейка 2: Успеваемость (оценки репетитора и школы) */}
<Panel padding="md">
<SectionHeader title="Успеваемость (репетитор и школа)" />
{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>
) : (
<Chart
type="line"
height={260}
series={gradesChart.series}
options={{
...chartOptionsBase,
xaxis: { ...chartOptionsBase.xaxis, categories: gradesChart.categories },
yaxis: { ...chartOptionsBase.yaxis, min: 1, max: 5, title: { text: 'Оценка (15)' } },
}}
/>
)}
</Panel>
{/* Ячейка 3: Успеваемость по ДЗ */}
<Panel padding="md">
<SectionHeader title="Успеваемость по ДЗ" />
{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>
) : (
<Chart
type="line"
height={260}
series={homeworkChart.series}
options={{
...chartOptionsBase,
colors: ['#6750A4'],
xaxis: { ...chartOptionsBase.xaxis, categories: homeworkChart.categories },
yaxis: { ...chartOptionsBase.yaxis, min: 0, max: 100, title: { text: 'Оценка' } },
}}
/>
)}
</Panel>
{/* Ячейка 4: Посещаемость — проведённые занятия по датам */}
<Panel padding="md">
<SectionHeader title="Посещаемость" />
{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>
) : (
<Chart
type="line"
height={260}
series={attendanceChart.series}
options={{
...chartOptionsBase,
colors: ['#6750A4'],
xaxis: { ...chartOptionsBase.xaxis, categories: attendanceChart.categories },
yaxis: {
...chartOptionsBase.yaxis,
title: { text: 'Занятий' },
min: 0,
tickAmount: 4,
labels: {
...(chartOptionsBase.yaxis?.labels ?? {}),
formatter: (val: number) => String(Math.round(val)),
},
},
}}
/>
)}
</Panel>
</DashboardLayout>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,26 +1,27 @@
'use client';
import { ReferralsPageContent } from '@/components/referrals/ReferralsPageContent';
export default function ReferralsPage() {
return (
<div
style={{
minHeight: '100vh',
padding: 24,
background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)',
}}
>
<div
style={{
background: '#fff',
borderRadius: 20,
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
padding: 24,
}}
>
<ReferralsPageContent />
</div>
</div>
);
}
'use client';
import { ReferralsPageContent } from '@/components/referrals/ReferralsPageContent';
export default function ReferralsPage() {
return (
<div
className="page-referrals"
style={{
padding: 24,
background: 'linear-gradient(135deg, #F6F7FF 0%, #FAF8FF 50%, #FFF8F5 100%)',
}}
>
<div
className="page-referrals-card"
style={{
background: '#fff',
borderRadius: 20,
boxShadow: '0 4px 24px rgba(0,0,0,0.06)',
padding: 24,
}}
>
<ReferralsPageContent />
</div>
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@ -1,464 +1,464 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
startOfDay,
format,
addDays,
subDays,
subMonths,
addMonths,
startOfMonth,
endOfMonth,
differenceInMinutes,
} from 'date-fns';
import { ru } from 'date-fns/locale';
import { Calendar } from '@/components/calendar/calendar';
import { CheckLesson } from '@/components/checklesson/checklesson';
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
import { getStudents } from '@/api/students';
import { useAuth } from '@/contexts/AuthContext';
import { useSelectedChild } from '@/contexts/SelectedChildContext';
import { getSubjects, getMentorSubjects } from '@/api/subjects';
import { loadComponent } from '@/lib/material-components';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ErrorDisplay } from '@/components/common/ErrorDisplay';
import type { CalendarLesson } from '@/components/calendar/calendar';
import type { CheckLessonFormData, CheckLessonProps } from '@/components/checklesson/checklesson';
import type { LessonPreview } from '@/api/dashboard';
import type { Student } from '@/api/students';
import type { Subject, MentorSubject } from '@/api/subjects';
export default function SchedulePage() {
const { user } = useAuth();
const { selectedChild } = useSelectedChild();
const isMentor = user?.role === 'mentor';
const [selectedDate, setSelectedDate] = useState<Date>(startOfDay(new Date()));
const [displayDate, setDisplayDate] = useState<Date>(startOfDay(new Date()));
const [visibleMonth, setVisibleMonth] = useState<Date>(() => startOfMonth(new Date()));
const [lessons, setLessons] = useState<CalendarLesson[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const formDataLoadedRef = useRef(false);
const hasLoadedLessonsOnceRef = useRef(false);
// Форма
const [isFormVisible, setIsFormVisible] = useState(false);
const [isEditingMode, setIsEditingMode] = useState(false);
const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [formData, setFormData] = useState<CheckLessonFormData>({
client: '',
title: '',
description: '',
start_date: format(selectedDate, 'yyyy-MM-dd'),
start_time: '14:00',
duration: 60,
price: undefined,
is_recurring: false,
});
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
const [selectedMentorSubjectId, setSelectedMentorSubjectId] = useState<number | null>(null);
const [editingLessonId, setEditingLessonId] = useState<string | null>(null);
// Компоненты Material Web
const [buttonComponentsLoaded, setButtonComponentsLoaded] = useState(false);
const [formComponentsLoaded, setFormComponentsLoaded] = useState(false);
// Данные для формы
const [students, setStudents] = useState<Student[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [mentorSubjects, setMentorSubjects] = useState<MentorSubject[]>([]);
const [lessonEditLoading, setLessonEditLoading] = useState(false);
useEffect(() => {
Promise.all([
loadComponent('elevated-card'),
loadComponent('filled-button'),
loadComponent('icon'),
]).then(() => {
setButtonComponentsLoaded(true);
});
Promise.all([
loadComponent('filled-button'),
loadComponent('outlined-button'),
loadComponent('text-field'),
loadComponent('select'),
loadComponent('switch'),
]).then(() => {
setFormComponentsLoaded(true);
});
}, []);
useEffect(() => {
if (!isFormVisible || formDataLoadedRef.current) return;
(async () => {
try {
const [studentsResp, subjectsResp, mentorSubjectsResp] = await Promise.all([
getStudents({ page: 1, page_size: 200 }),
getSubjects(),
getMentorSubjects(),
]);
setStudents(studentsResp.results || []);
setSubjects(subjectsResp || []);
setMentorSubjects(mentorSubjectsResp || []);
formDataLoadedRef.current = true;
} catch (err) {
console.error('Error loading form data:', err);
}
})();
}, [isFormVisible]);
const loadLessons = useCallback(async () => {
const start = startOfMonth(subMonths(visibleMonth, 1));
const end = endOfMonth(addMonths(visibleMonth, 1));
const isInitial = !hasLoadedLessonsOnceRef.current;
try {
if (isInitial) setLessonsLoading(true);
setError(null);
const { lessons: lessonsData } = await getLessonsCalendar({
start_date: format(start, 'yyyy-MM-dd'),
end_date: format(end, 'yyyy-MM-dd'),
...(selectedChild?.id && { child_id: selectedChild.id }),
});
const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({
id: lesson.id,
title: lesson.title,
start_time: lesson.start_time,
end_time: lesson.end_time,
status: lesson.status,
client: lesson.client?.id,
client_name: lesson.client_name ?? (lesson.client?.user
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
: undefined),
subject: lesson.subject ?? lesson.subject_name ?? '',
}));
setLessons(mappedLessons);
hasLoadedLessonsOnceRef.current = true;
} catch (err: any) {
console.error('Error loading lessons:', err);
setError(err?.message || 'Ошибка загрузки занятий');
} finally {
if (isInitial) setLessonsLoading(false);
}
}, [visibleMonth, selectedChild?.id]);
useEffect(() => {
loadLessons();
}, [loadLessons]);
const handleMonthChange = useCallback((start: Date, _end: Date) => {
const key = format(start, 'yyyy-MM');
if (key === format(visibleMonth, 'yyyy-MM')) return;
setVisibleMonth(startOfMonth(start));
}, [visibleMonth]);
const lessonsForSelectedDate: LessonPreview[] = lessons
.filter((lesson) => {
const lessonDate = startOfDay(new Date(lesson.start_time));
return lessonDate.getTime() === selectedDate.getTime();
})
.map((lesson) => ({
id: String(lesson.id),
title: lesson.title || 'Занятие',
subject: lesson.subject ?? '',
start_time: lesson.start_time,
end_time: lesson.end_time,
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
client: lesson.client_name
? {
id: String(lesson.client || ''),
name: lesson.client_name,
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
}
: undefined,
}));
const handleSelectSlot = (date: Date) => {
const dayStart = startOfDay(date);
setSelectedDate(dayStart);
setDisplayDate(dayStart);
setIsFormVisible(false);
};
const handleSelectEvent = (lesson: { id: string }) => {
if (isMentor) handleLessonClick(lesson);
};
const handlePrevDay = () => {
const prev = subDays(displayDate, 1);
setDisplayDate(prev);
setSelectedDate(startOfDay(prev));
setIsFormVisible(false);
};
const handleNextDay = () => {
const next = addDays(displayDate, 1);
setDisplayDate(next);
setSelectedDate(startOfDay(next));
setIsFormVisible(false);
};
const handleAddLesson = () => {
setIsEditingMode(false);
setFormData({
client: '',
title: '',
description: '',
start_date: format(selectedDate, 'yyyy-MM-dd'),
start_time: '14:00',
duration: 60,
price: undefined,
is_recurring: false,
});
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
setIsFormVisible(true);
};
const handleLessonClick = (lesson: { id: string }) => {
if (!isMentor) return; // Добавить/редактировать/просмотр — только для ментора
setIsEditingMode(true);
setIsFormVisible(true);
setLessonEditLoading(true);
setFormError(null);
setEditingLessonId(lesson.id);
(async () => {
try {
const details = await getLesson(String(lesson.id));
const start = new Date(details.start_time);
const end = new Date(details.end_time);
const safeStart = startOfDay(start);
// синхронизируем правую панель с датой урока
setSelectedDate(safeStart);
setDisplayDate(safeStart);
const duration = (() => {
const mins = differenceInMinutes(end, start);
return Number.isFinite(mins) && mins > 0 ? mins : 60;
})();
setFormData({
client: details.client?.id ? String(details.client.id) : '',
title: details.title ?? '',
description: details.description ?? '',
start_date: format(start, 'yyyy-MM-dd'),
start_time: format(start, 'HH:mm'),
duration,
price: typeof details.price === 'number' ? details.price : undefined,
is_recurring: !!(details as any).is_recurring,
});
// пробуем выставить предмет по названию
const subjName = (details as any).subject_name || (details as any).subject || '';
if (subjName) {
const foundSubject = subjects.find((s) => s.name === subjName);
const foundMentorSubject = mentorSubjects.find((s) => s.name === subjName);
if (foundMentorSubject) {
setSelectedSubjectId(null);
setSelectedMentorSubjectId(foundMentorSubject.id);
} else if (foundSubject) {
setSelectedSubjectId(foundSubject.id);
setSelectedMentorSubjectId(null);
} else {
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
}
} else {
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
}
} catch (err: any) {
console.error('Error loading lesson:', err);
setFormError(err?.message || 'Не удалось загрузить данные занятия');
} finally {
setLessonEditLoading(false);
}
})();
};
const handleSubjectChange = (subjectId: number | null, mentorSubjectId: number | null) => {
setSelectedSubjectId(subjectId);
setSelectedMentorSubjectId(mentorSubjectId);
};
const getSubjectName = () => {
if (selectedSubjectId) {
const s = subjects.find((x) => x.id === selectedSubjectId);
return s?.name ?? '';
}
if (selectedMentorSubjectId) {
const s = mentorSubjects.find((x) => x.id === selectedMentorSubjectId);
return s?.name ?? '';
}
return '';
};
const generateTitle = () => {
const student = students.find((s) => String(s.id) === formData.client);
const studentName = student
? `${student.user?.first_name || ''} ${student.user?.last_name || ''}`.trim() || student.user?.email
: '';
const subjectName = getSubjectName();
if (studentName && subjectName) return `${subjectName}${studentName}`;
if (studentName) return studentName;
if (subjectName) return subjectName;
return formData.title || 'Занятие';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormLoading(true);
setFormError(null);
try {
if (!formData.client) {
setFormError('Выберите ученика');
setFormLoading(false);
return;
}
if (!selectedSubjectId && !selectedMentorSubjectId) {
setFormError('Выберите предмет');
setFormLoading(false);
return;
}
if (!formData.start_date || !formData.start_time) {
setFormError('Укажите дату и время');
setFormLoading(false);
return;
}
if (formData.price == null || formData.price < 0) {
setFormError('Укажите стоимость занятия');
setFormLoading(false);
return;
}
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString();
const title = generateTitle();
if (isEditingMode && editingLessonId) {
await updateLesson(editingLessonId, {
title,
description: formData.description,
start_time: startUtc,
duration: formData.duration,
price: formData.price,
});
} else {
const payload: any = {
client: formData.client,
title,
description: formData.description,
start_time: startUtc,
duration: formData.duration,
price: formData.price,
is_recurring: formData.is_recurring,
};
if (selectedSubjectId) payload.subject_id = selectedSubjectId;
else if (selectedMentorSubjectId) payload.mentor_subject_id = selectedMentorSubjectId;
await createLesson(payload);
}
setIsFormVisible(false);
setEditingLessonId(null);
loadLessons();
} catch (err: any) {
const msg = err?.response?.data
? typeof err.response.data === 'object'
? Object.entries(err.response.data)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
.join('\n')
: String(err.response.data)
: err?.message || 'Ошибка сохранения занятия';
setFormError(msg);
} finally {
setFormLoading(false);
}
};
const handleDelete = async (deleteAllFuture: boolean) => {
if (!editingLessonId) return;
setFormLoading(true);
setFormError(null);
try {
await deleteLesson(editingLessonId, deleteAllFuture);
setIsFormVisible(false);
setEditingLessonId(null);
loadLessons();
} catch (err: any) {
setFormError(err?.message || 'Ошибка удаления занятия');
} finally {
setFormLoading(false);
}
};
const handleCancel = () => {
setIsFormVisible(false);
setIsEditingMode(false);
setFormError(null);
setEditingLessonId(null);
};
return (
<div className="ios26-dashboard ios26-schedule-page" style={{ padding: '16px' }}>
{error && <ErrorDisplay error={error} onRetry={loadLessons} />}
<div className="ios26-schedule-layout" style={{
display: 'grid',
gridTemplateColumns: '5fr 2fr',
gap: 'var(--ios26-spacing)',
alignItems: 'stretch',
// стабилизируем высоту секции (без фиксированных px),
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
minHeight: 'calc(100vh - 160px)',
}}>
<div className="ios26-schedule-calendar-wrap">
<Calendar
lessons={lessons}
lessonsLoading={lessonsLoading}
selectedDate={selectedDate}
onSelectSlot={handleSelectSlot}
onSelectEvent={handleSelectEvent}
onMonthChange={handleMonthChange}
/>
</div>
<div className="ios26-schedule-right-wrap">
<CheckLesson
selectedDate={selectedDate}
displayDate={displayDate}
lessonsLoading={lessonsLoading}
lessonsForSelectedDate={lessonsForSelectedDate}
isFormVisible={isFormVisible}
isMentor={isMentor}
onPrevDay={handlePrevDay}
onNextDay={handleNextDay}
onAddLesson={handleAddLesson}
onLessonClick={handleLessonClick}
buttonComponentsLoaded={buttonComponentsLoaded}
formComponentsLoaded={formComponentsLoaded}
lessonEditLoading={lessonEditLoading}
isEditingMode={isEditingMode}
formLoading={formLoading}
formError={formError}
formData={formData}
setFormData={setFormData}
selectedSubjectId={selectedSubjectId}
selectedMentorSubjectId={selectedMentorSubjectId}
onSubjectChange={handleSubjectChange}
students={students}
subjects={subjects}
mentorSubjects={mentorSubjects}
onSubmit={handleSubmit}
onCancel={handleCancel}
onDelete={isEditingMode ? handleDelete : undefined}
/>
</div>
</div>
</div>
);
}
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import {
startOfDay,
format,
addDays,
subDays,
subMonths,
addMonths,
startOfMonth,
endOfMonth,
differenceInMinutes,
} from 'date-fns';
import { ru } from 'date-fns/locale';
import { Calendar } from '@/components/calendar/calendar';
import { CheckLesson } from '@/components/checklesson/checklesson';
import { getLessonsCalendar, getLesson, createLesson, updateLesson, deleteLesson } from '@/api/schedule';
import { getStudents } from '@/api/students';
import { useAuth } from '@/contexts/AuthContext';
import { useSelectedChild } from '@/contexts/SelectedChildContext';
import { getSubjects, getMentorSubjects } from '@/api/subjects';
import { loadComponent } from '@/lib/material-components';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { ErrorDisplay } from '@/components/common/ErrorDisplay';
import type { CalendarLesson } from '@/components/calendar/calendar';
import type { CheckLessonFormData, CheckLessonProps } from '@/components/checklesson/checklesson';
import type { LessonPreview } from '@/api/dashboard';
import type { Student } from '@/api/students';
import type { Subject, MentorSubject } from '@/api/subjects';
export default function SchedulePage() {
const { user } = useAuth();
const { selectedChild } = useSelectedChild();
const isMentor = user?.role === 'mentor';
const [selectedDate, setSelectedDate] = useState<Date>(startOfDay(new Date()));
const [displayDate, setDisplayDate] = useState<Date>(startOfDay(new Date()));
const [visibleMonth, setVisibleMonth] = useState<Date>(() => startOfMonth(new Date()));
const [lessons, setLessons] = useState<CalendarLesson[]>([]);
const [lessonsLoading, setLessonsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const formDataLoadedRef = useRef(false);
const hasLoadedLessonsOnceRef = useRef(false);
// Форма
const [isFormVisible, setIsFormVisible] = useState(false);
const [isEditingMode, setIsEditingMode] = useState(false);
const [formLoading, setFormLoading] = useState(false);
const [formError, setFormError] = useState<string | null>(null);
const [formData, setFormData] = useState<CheckLessonFormData>({
client: '',
title: '',
description: '',
start_date: format(selectedDate, 'yyyy-MM-dd'),
start_time: '14:00',
duration: 60,
price: undefined,
is_recurring: false,
});
const [selectedSubjectId, setSelectedSubjectId] = useState<number | null>(null);
const [selectedMentorSubjectId, setSelectedMentorSubjectId] = useState<number | null>(null);
const [editingLessonId, setEditingLessonId] = useState<string | null>(null);
// Компоненты Material Web
const [buttonComponentsLoaded, setButtonComponentsLoaded] = useState(false);
const [formComponentsLoaded, setFormComponentsLoaded] = useState(false);
// Данные для формы
const [students, setStudents] = useState<Student[]>([]);
const [subjects, setSubjects] = useState<Subject[]>([]);
const [mentorSubjects, setMentorSubjects] = useState<MentorSubject[]>([]);
const [lessonEditLoading, setLessonEditLoading] = useState(false);
useEffect(() => {
Promise.all([
loadComponent('elevated-card'),
loadComponent('filled-button'),
loadComponent('icon'),
]).then(() => {
setButtonComponentsLoaded(true);
});
Promise.all([
loadComponent('filled-button'),
loadComponent('outlined-button'),
loadComponent('text-field'),
loadComponent('select'),
loadComponent('switch'),
]).then(() => {
setFormComponentsLoaded(true);
});
}, []);
useEffect(() => {
if (!isFormVisible || formDataLoadedRef.current) return;
(async () => {
try {
const [studentsResp, subjectsResp, mentorSubjectsResp] = await Promise.all([
getStudents({ page: 1, page_size: 200 }),
getSubjects(),
getMentorSubjects(),
]);
setStudents(studentsResp.results || []);
setSubjects(subjectsResp || []);
setMentorSubjects(mentorSubjectsResp || []);
formDataLoadedRef.current = true;
} catch (err) {
console.error('Error loading form data:', err);
}
})();
}, [isFormVisible]);
const loadLessons = useCallback(async () => {
const start = startOfMonth(subMonths(visibleMonth, 1));
const end = endOfMonth(addMonths(visibleMonth, 1));
const isInitial = !hasLoadedLessonsOnceRef.current;
try {
if (isInitial) setLessonsLoading(true);
setError(null);
const { lessons: lessonsData } = await getLessonsCalendar({
start_date: format(start, 'yyyy-MM-dd'),
end_date: format(end, 'yyyy-MM-dd'),
...(selectedChild?.id && { child_id: selectedChild.id }),
});
const mappedLessons: CalendarLesson[] = (lessonsData || []).map((lesson: any) => ({
id: lesson.id,
title: lesson.title,
start_time: lesson.start_time,
end_time: lesson.end_time,
status: lesson.status,
client: lesson.client?.id,
client_name: lesson.client_name ?? (lesson.client?.user
? `${lesson.client.user.first_name || ''} ${lesson.client.user.last_name || ''}`.trim()
: undefined),
subject: lesson.subject ?? lesson.subject_name ?? '',
}));
setLessons(mappedLessons);
hasLoadedLessonsOnceRef.current = true;
} catch (err: any) {
console.error('Error loading lessons:', err);
setError(err?.message || 'Ошибка загрузки занятий');
} finally {
if (isInitial) setLessonsLoading(false);
}
}, [visibleMonth, selectedChild?.id]);
useEffect(() => {
loadLessons();
}, [loadLessons]);
const handleMonthChange = useCallback((start: Date, _end: Date) => {
const key = format(start, 'yyyy-MM');
if (key === format(visibleMonth, 'yyyy-MM')) return;
setVisibleMonth(startOfMonth(start));
}, [visibleMonth]);
const lessonsForSelectedDate: LessonPreview[] = lessons
.filter((lesson) => {
const lessonDate = startOfDay(new Date(lesson.start_time));
return lessonDate.getTime() === selectedDate.getTime();
})
.map((lesson) => ({
id: String(lesson.id),
title: lesson.title || 'Занятие',
subject: lesson.subject ?? '',
start_time: lesson.start_time,
end_time: lesson.end_time,
status: (lesson.status || 'scheduled') as 'scheduled' | 'in_progress' | 'completed' | 'cancelled',
client: lesson.client_name
? {
id: String(lesson.client || ''),
name: lesson.client_name,
first_name: lesson.client_name.split(' ')[0] || lesson.client_name,
last_name: lesson.client_name.split(' ').slice(1).join(' ') || '',
}
: undefined,
}));
const handleSelectSlot = (date: Date) => {
const dayStart = startOfDay(date);
setSelectedDate(dayStart);
setDisplayDate(dayStart);
setIsFormVisible(false);
};
const handleSelectEvent = (lesson: { id: string }) => {
if (isMentor) handleLessonClick(lesson);
};
const handlePrevDay = () => {
const prev = subDays(displayDate, 1);
setDisplayDate(prev);
setSelectedDate(startOfDay(prev));
setIsFormVisible(false);
};
const handleNextDay = () => {
const next = addDays(displayDate, 1);
setDisplayDate(next);
setSelectedDate(startOfDay(next));
setIsFormVisible(false);
};
const handleAddLesson = () => {
setIsEditingMode(false);
setFormData({
client: '',
title: '',
description: '',
start_date: format(selectedDate, 'yyyy-MM-dd'),
start_time: '14:00',
duration: 60,
price: undefined,
is_recurring: false,
});
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
setIsFormVisible(true);
};
const handleLessonClick = (lesson: { id: string }) => {
if (!isMentor) return; // Добавить/редактировать/просмотр — только для ментора
setIsEditingMode(true);
setIsFormVisible(true);
setLessonEditLoading(true);
setFormError(null);
setEditingLessonId(lesson.id);
(async () => {
try {
const details = await getLesson(String(lesson.id));
const start = new Date(details.start_time);
const end = new Date(details.end_time);
const safeStart = startOfDay(start);
// синхронизируем правую панель с датой урока
setSelectedDate(safeStart);
setDisplayDate(safeStart);
const duration = (() => {
const mins = differenceInMinutes(end, start);
return Number.isFinite(mins) && mins > 0 ? mins : 60;
})();
setFormData({
client: details.client?.id ? String(details.client.id) : '',
title: details.title ?? '',
description: details.description ?? '',
start_date: format(start, 'yyyy-MM-dd'),
start_time: format(start, 'HH:mm'),
duration,
price: typeof details.price === 'number' ? details.price : undefined,
is_recurring: !!(details as any).is_recurring,
});
// пробуем выставить предмет по названию
const subjName = (details as any).subject_name || (details as any).subject || '';
if (subjName) {
const foundSubject = subjects.find((s) => s.name === subjName);
const foundMentorSubject = mentorSubjects.find((s) => s.name === subjName);
if (foundMentorSubject) {
setSelectedSubjectId(null);
setSelectedMentorSubjectId(foundMentorSubject.id);
} else if (foundSubject) {
setSelectedSubjectId(foundSubject.id);
setSelectedMentorSubjectId(null);
} else {
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
}
} else {
setSelectedSubjectId(null);
setSelectedMentorSubjectId(null);
}
} catch (err: any) {
console.error('Error loading lesson:', err);
setFormError(err?.message || 'Не удалось загрузить данные занятия');
} finally {
setLessonEditLoading(false);
}
})();
};
const handleSubjectChange = (subjectId: number | null, mentorSubjectId: number | null) => {
setSelectedSubjectId(subjectId);
setSelectedMentorSubjectId(mentorSubjectId);
};
const getSubjectName = () => {
if (selectedSubjectId) {
const s = subjects.find((x) => x.id === selectedSubjectId);
return s?.name ?? '';
}
if (selectedMentorSubjectId) {
const s = mentorSubjects.find((x) => x.id === selectedMentorSubjectId);
return s?.name ?? '';
}
return '';
};
const generateTitle = () => {
const student = students.find((s) => String(s.id) === formData.client);
const studentName = student
? `${student.user?.first_name || ''} ${student.user?.last_name || ''}`.trim() || student.user?.email
: '';
const subjectName = getSubjectName();
if (studentName && subjectName) return `${subjectName}${studentName}`;
if (studentName) return studentName;
if (subjectName) return subjectName;
return formData.title || 'Занятие';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormLoading(true);
setFormError(null);
try {
if (!formData.client) {
setFormError('Выберите ученика');
setFormLoading(false);
return;
}
if (!selectedSubjectId && !selectedMentorSubjectId) {
setFormError('Выберите предмет');
setFormLoading(false);
return;
}
if (!formData.start_date || !formData.start_time) {
setFormError('Укажите дату и время');
setFormLoading(false);
return;
}
if (formData.price == null || formData.price < 0) {
setFormError('Укажите стоимость занятия');
setFormLoading(false);
return;
}
const startUtc = new Date(`${formData.start_date}T${formData.start_time}`).toISOString();
const title = generateTitle();
if (isEditingMode && editingLessonId) {
await updateLesson(editingLessonId, {
title,
description: formData.description,
start_time: startUtc,
duration: formData.duration,
price: formData.price,
});
} else {
const payload: any = {
client: formData.client,
title,
description: formData.description,
start_time: startUtc,
duration: formData.duration,
price: formData.price,
is_recurring: formData.is_recurring,
};
if (selectedSubjectId) payload.subject_id = selectedSubjectId;
else if (selectedMentorSubjectId) payload.mentor_subject_id = selectedMentorSubjectId;
await createLesson(payload);
}
setIsFormVisible(false);
setEditingLessonId(null);
loadLessons();
} catch (err: any) {
const msg = err?.response?.data
? typeof err.response.data === 'object'
? Object.entries(err.response.data)
.map(([k, v]) => `${k}: ${Array.isArray(v) ? v.join(', ') : v}`)
.join('\n')
: String(err.response.data)
: err?.message || 'Ошибка сохранения занятия';
setFormError(msg);
} finally {
setFormLoading(false);
}
};
const handleDelete = async (deleteAllFuture: boolean) => {
if (!editingLessonId) return;
setFormLoading(true);
setFormError(null);
try {
await deleteLesson(editingLessonId, deleteAllFuture);
setIsFormVisible(false);
setEditingLessonId(null);
loadLessons();
} catch (err: any) {
setFormError(err?.message || 'Ошибка удаления занятия');
} finally {
setFormLoading(false);
}
};
const handleCancel = () => {
setIsFormVisible(false);
setIsEditingMode(false);
setFormError(null);
setEditingLessonId(null);
};
return (
<div className="ios26-dashboard ios26-schedule-page" style={{ padding: '16px' }}>
{error && <ErrorDisplay error={error} onRetry={loadLessons} />}
<div className="ios26-schedule-layout" style={{
display: 'grid',
gridTemplateColumns: '5fr 2fr',
gap: 'var(--ios26-spacing)',
alignItems: 'stretch',
// стабилизируем высоту секции (без фиксированных px),
// чтобы календарь не “схлопывался” на месяцах с меньшим количеством контента
minHeight: 'min(calc(100vh - 160px), 600px)',
}}>
<div className="ios26-schedule-calendar-wrap">
<Calendar
lessons={lessons}
lessonsLoading={lessonsLoading}
selectedDate={selectedDate}
onSelectSlot={handleSelectSlot}
onSelectEvent={handleSelectEvent}
onMonthChange={handleMonthChange}
/>
</div>
<div className="ios26-schedule-right-wrap">
<CheckLesson
selectedDate={selectedDate}
displayDate={displayDate}
lessonsLoading={lessonsLoading}
lessonsForSelectedDate={lessonsForSelectedDate}
isFormVisible={isFormVisible}
isMentor={isMentor}
onPrevDay={handlePrevDay}
onNextDay={handleNextDay}
onAddLesson={handleAddLesson}
onLessonClick={handleLessonClick}
buttonComponentsLoaded={buttonComponentsLoaded}
formComponentsLoaded={formComponentsLoaded}
lessonEditLoading={lessonEditLoading}
isEditingMode={isEditingMode}
formLoading={formLoading}
formError={formError}
formData={formData}
setFormData={setFormData}
selectedSubjectId={selectedSubjectId}
selectedMentorSubjectId={selectedMentorSubjectId}
onSubjectChange={handleSubjectChange}
students={students}
subjects={subjects}
mentorSubjects={mentorSubjects}
onSubmit={handleSubmit}
onCancel={handleCancel}
onDelete={isEditingMode ? handleDelete : undefined}
/>
</div>
</div>
</div>
);
}

View File

@ -416,18 +416,20 @@ export default function StudentsPage() {
className="page-students"
style={{
padding: '24px',
minHeight: '100vh',
}}
>
{/* Табы: Студенты | Запросы на менторство | Ожидают ответа — если есть соответствующие данные */}
{(mentorshipRequests.length > 0 || pendingInvitations.length > 0) && (
<div
className="students-tabs"
style={{
display: 'flex',
gap: 4,
marginBottom: 24,
borderBottom: '1px solid var(--ios26-list-divider, rgba(0,0,0,0.12))',
paddingBottom: 0,
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<button
@ -1408,8 +1410,8 @@ export default function StudentsPage() {
sx: {
mt: 1,
borderRadius: 12,
minWidth: 300,
maxWidth: 360,
minWidth: { xs: 'calc(100vw - 32px)', sm: 300 },
maxWidth: { xs: 'calc(100vw - 32px)', sm: 360 },
overflow: 'hidden',
},
},

View File

@ -1,221 +1,221 @@
/**
* Публичная страница регистрации по ссылке-приглашению (Material UI версия)
*/
'use client';
import React, { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { getMentorInfoByToken, registerByLink } from '@/api/students';
import { useAuth } from '@/contexts/AuthContext';
import { getErrorMessage } from '@/lib/error-utils';
const loadMaterialComponents = async () => {
await Promise.all([
import('@material/web/textfield/filled-text-field.js'),
import('@material/web/button/filled-button.js'),
import('@material/web/button/text-button.js'),
]);
};
export default function InvitationPage() {
const { token } = useParams();
const router = useRouter();
const { login: authLogin } = useAuth();
const [mounted, setMounted] = useState(false);
const [mentor, setMentor] = useState<{ mentor_name: string; avatar_url: string | null } | null>(null);
const [loading, setLoading] = useState(true);
const [registering, setRegistering] = useState(false);
const [error, setError] = useState<string | null>(null);
const [componentsLoaded, setComponentsLoaded] = useState(false);
const [formData, setFormData] = useState({
first_name: '',
last_name: '',
email: '',
password: '',
});
useEffect(() => {
setMounted(true);
loadMaterialComponents()
.then(() => setComponentsLoaded(true))
.catch((err) => {
console.error('Error loading Material components:', err);
setComponentsLoaded(true);
});
}, []);
useEffect(() => {
const fetchMentor = async () => {
try {
const data = await getMentorInfoByToken(token as string);
setMentor(data);
} catch (err: any) {
setError('Недействительная или просроченная ссылка');
} finally {
setLoading(false);
}
};
if (token) {
fetchMentor();
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setRegistering(true);
try {
const response = await registerByLink({
token: token as string,
...formData,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
// Автоматический вход после регистрации
if (response.access) {
localStorage.setItem('access_token', response.access);
if (response.refresh) {
localStorage.setItem('refresh_token', response.refresh);
}
if (response.user) {
await authLogin(response.access, response.user);
} else {
await authLogin(response.access);
}
window.location.href = '/dashboard';
}
} catch (err: any) {
setError(getErrorMessage(err, 'Ошибка при регистрации'));
setRegistering(false);
}
};
if (!mounted || loading || !componentsLoaded) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px', height: '100vh', alignItems: 'center', background: '#f8f9fa' }}>
<div
style={{
width: '40px',
height: '40px',
border: '3px solid #e0e0e0',
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
</div>
);
}
if (error && !mentor) {
return (
<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={{ color: '#c62828', marginBottom: 16 }}>
<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" />
</svg>
</div>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>Упс!</h1>
<p style={{ color: '#666', marginBottom: 24 }}>{error}</p>
<md-filled-button onClick={() => router.push('/')} style={{ width: '100%' }}>
На главную
</md-filled-button>
</div>
</div>
);
}
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={{ maxWidth: '400px', width: '100%' }}>
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{ marginBottom: '24px' }}>
<img
src="/logo/logo.svg"
alt="Uchill Logo"
style={{ width: '120px', height: 'auto' }}
/>
</div>
<h1 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)' }}>
Вас пригласил ментор <span style={{ fontWeight: '600', color: 'var(--md-sys-color-primary)' }}>{mentor?.mentor_name}</span>
</p>
{mentor?.avatar_url && (
<div style={{ marginTop: '20px', display: 'flex', justifyContent: 'center' }}>
<img
src={mentor.avatar_url}
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)' }}
/>
</div>
)}
</div>
<div 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' }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<md-filled-text-field
label="Имя"
value={formData.first_name}
onInput={(e: any) => setFormData({ ...formData, first_name: e.target.value })}
required
style={{ width: '100%' }}
/>
<md-filled-text-field
label="Фамилия"
value={formData.last_name}
onInput={(e: any) => setFormData({ ...formData, last_name: e.target.value })}
required
style={{ width: '100%' }}
/>
</div>
<md-filled-text-field
label="Email"
type="email"
value={formData.email}
onInput={(e: any) => setFormData({ ...formData, email: e.target.value })}
required
style={{ width: '100%' }}
/>
<md-filled-text-field
label="Пароль"
type="password"
value={formData.password}
onInput={(e: any) => setFormData({ ...formData, password: e.target.value })}
required
style={{ width: '100%' }}
/>
{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' }}>
{error}
</div>
)}
<md-filled-button
type="submit"
disabled={registering}
style={{ height: '56px', fontSize: '16px', fontWeight: '600', borderRadius: '16px' }}
>
{registering ? 'Регистрация...' : 'Начать обучение'}
</md-filled-button>
</form>
</div>
<div style={{ textAlign: 'center', marginTop: '24px' }}>
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
Уже есть аккаунт? Войти
</md-text-button>
</div>
</div>
</div>
);
}
/**
* Публичная страница регистрации по ссылке-приглашению (Material UI версия)
*/
'use client';
import React, { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { getMentorInfoByToken, registerByLink } from '@/api/students';
import { useAuth } from '@/contexts/AuthContext';
import { getErrorMessage } from '@/lib/error-utils';
const loadMaterialComponents = async () => {
await Promise.all([
import('@material/web/textfield/filled-text-field.js'),
import('@material/web/button/filled-button.js'),
import('@material/web/button/text-button.js'),
]);
};
export default function InvitationPage() {
const { token } = useParams();
const router = useRouter();
const { login: authLogin } = useAuth();
const [mounted, setMounted] = useState(false);
const [mentor, setMentor] = useState<{ mentor_name: string; avatar_url: string | null } | null>(null);
const [loading, setLoading] = useState(true);
const [registering, setRegistering] = useState(false);
const [error, setError] = useState<string | null>(null);
const [componentsLoaded, setComponentsLoaded] = useState(false);
const [formData, setFormData] = useState({
first_name: '',
last_name: '',
email: '',
password: '',
});
useEffect(() => {
setMounted(true);
loadMaterialComponents()
.then(() => setComponentsLoaded(true))
.catch((err) => {
console.error('Error loading Material components:', err);
setComponentsLoaded(true);
});
}, []);
useEffect(() => {
const fetchMentor = async () => {
try {
const data = await getMentorInfoByToken(token as string);
setMentor(data);
} catch (err: any) {
setError('Недействительная или просроченная ссылка');
} finally {
setLoading(false);
}
};
if (token) {
fetchMentor();
}
}, [token]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setRegistering(true);
try {
const response = await registerByLink({
token: token as string,
...formData,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
});
// Автоматический вход после регистрации
if (response.access) {
localStorage.setItem('access_token', response.access);
if (response.refresh) {
localStorage.setItem('refresh_token', response.refresh);
}
if (response.user) {
await authLogin(response.access, response.user);
} else {
await authLogin(response.access);
}
window.location.href = '/dashboard';
}
} catch (err: any) {
setError(getErrorMessage(err, 'Ошибка при регистрации'));
setRegistering(false);
}
};
if (!mounted || loading || !componentsLoaded) {
return (
<div style={{ display: 'flex', justifyContent: 'center', padding: '48px', height: '100vh', alignItems: 'center', background: '#f8f9fa' }}>
<div
style={{
width: '40px',
height: '40px',
border: '3px solid #e0e0e0',
borderTopColor: 'var(--md-sys-color-primary, #6750a4)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
}}
/>
</div>
);
}
if (error && !mentor) {
return (
<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={{ color: '#c62828', marginBottom: 16 }}>
<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" />
</svg>
</div>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 8 }}>Упс!</h1>
<p style={{ color: '#666', marginBottom: 24 }}>{error}</p>
<md-filled-button onClick={() => router.push('/')} style={{ width: '100%' }}>
На главную
</md-filled-button>
</div>
</div>
);
}
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={{ maxWidth: '400px', width: '100%' }}>
<div style={{ textAlign: 'center', marginBottom: '32px' }}>
<div style={{ marginBottom: '24px' }}>
<img
src="/logo/logo.svg"
alt="Uchill Logo"
style={{ width: '120px', height: 'auto' }}
/>
</div>
<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)' }}>
Вас пригласил ментор <span style={{ fontWeight: '600', color: 'var(--md-sys-color-primary)' }}>{mentor?.mentor_name}</span>
</p>
{mentor?.avatar_url && (
<div style={{ marginTop: '20px', display: 'flex', justifyContent: 'center' }}>
<img
src={mentor.avatar_url}
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)' }}
/>
</div>
)}
</div>
<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' }}>
<div className="auth-name-grid" style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<md-filled-text-field
label="Имя"
value={formData.first_name}
onInput={(e: any) => setFormData({ ...formData, first_name: e.target.value })}
required
style={{ width: '100%' }}
/>
<md-filled-text-field
label="Фамилия"
value={formData.last_name}
onInput={(e: any) => setFormData({ ...formData, last_name: e.target.value })}
required
style={{ width: '100%' }}
/>
</div>
<md-filled-text-field
label="Email"
type="email"
value={formData.email}
onInput={(e: any) => setFormData({ ...formData, email: e.target.value })}
required
style={{ width: '100%' }}
/>
<md-filled-text-field
label="Пароль"
type="password"
value={formData.password}
onInput={(e: any) => setFormData({ ...formData, password: e.target.value })}
required
style={{ width: '100%' }}
/>
{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' }}>
{error}
</div>
)}
<md-filled-button
type="submit"
disabled={registering}
style={{ height: '56px', fontSize: '16px', fontWeight: '600', borderRadius: '16px' }}
>
{registering ? 'Регистрация...' : 'Начать обучение'}
</md-filled-button>
</form>
</div>
<div style={{ textAlign: 'center', marginTop: '24px' }}>
<md-text-button onClick={() => router.push('/login')} style={{ fontSize: '14px' }}>
Уже есть аккаунт? Войти
</md-text-button>
</div>
</div>
</div>
);
}

View File

@ -1,36 +1,48 @@
import type { Metadata } from 'next';
import { Providers } from './providers';
import '@/styles/globals.css';
import '@/styles/material-theme.css';
export const metadata: Metadata = {
title: 'Uchill Platform',
description: 'Образовательная платформа',
viewport: 'width=device-width, initial-scale=1, maximum-scale=5',
themeColor: '#7444FD',
icons: {
icon: '/favicon.png',
shortcut: '/favicon.png',
apple: '/favicon.png',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
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>
);
}
import type { Metadata } from 'next';
import { Providers } from './providers';
import '@/styles/globals.css';
import '@/styles/material-theme.css';
export const metadata: Metadata = {
title: 'Uchill Platform',
description: 'Образовательная платформа',
viewport: 'width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover',
themeColor: '#7444FD',
manifest: '/manifest.json',
icons: {
icon: '/icon.svg',
shortcut: '/icon.svg',
apple: '/icon.svg',
},
appleWebApp: {
capable: true,
statusBarStyle: 'black-translucent',
title: 'Uchill',
},
formatDetection: {
telephone: false,
},
other: {
'mobile-web-app-capable': 'yes',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
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>
);
}

View File

@ -1,45 +1,59 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
/**
* Если пользователь авторизован редирект на дашборд.
* Страницы логина/регистрации и т.д. не должны быть доступны авторизованным.
*/
export function AuthRedirect({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { user, loading } = useAuth();
useEffect(() => {
if (loading) return;
if (user) {
router.replace('/dashboard');
}
}, [user, loading, router]);
if (loading) {
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
}}
>
<div style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
Загрузка...
</div>
</div>
);
}
if (user) {
return null; // редирект уже идёт
}
return <>{children}</>;
}
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/contexts/AuthContext';
/**
* Если пользователь авторизован редирект на дашборд.
* Страницы логина/регистрации и т.д. не должны быть доступны авторизованным.
*/
export function AuthRedirect({ children }: { children: React.ReactNode }) {
const router = useRouter();
const { user, loading } = useAuth();
useEffect(() => {
if (loading) return;
if (user) {
router.replace('/dashboard');
}
}, [user, loading, router]);
if (loading) {
return (
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
}}
>
<div style={{ fontSize: 16, color: 'var(--md-sys-color-on-surface-variant)' }}>
Загрузка...
</div>
</div>
);
}
if (user) {
return (
<div
style={{
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}</>;
}

View File

@ -136,6 +136,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
return (
<div
className="checklesson-root"
style={{
position: 'relative',
width: '100%',
@ -385,6 +386,7 @@ export const CheckLesson: React.FC<CheckLessonProps> = ({
<>
<form
onSubmit={onSubmit}
className="checklesson-form"
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',

View File

@ -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';
import React, { useState, useRef, useEffect } from 'react';
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isSameMonth, addMonths, subMonths, startOfWeek, endOfWeek } from 'date-fns';
import React, { useState, useMemo } from 'react';
import {
format,
startOfMonth,
endOfMonth,
eachDayOfInterval,
isSameDay,
isSameMonth,
addMonths,
subMonths,
startOfWeek,
endOfWeek,
} from 'date-fns';
import { ru } from 'date-fns/locale';
import { Dialog, DialogContent, Box, Button } from '@mui/material';
interface DatePickerProps {
value: string; // YYYY-MM-DD format
onChange: (value: string) => void;
disabled?: boolean;
required?: boolean;
label?: string;
}
export const DatePicker: React.FC<DatePickerProps> = ({
@ -20,66 +34,65 @@ export const DatePicker: React.FC<DatePickerProps> = ({
onChange,
disabled = false,
required = false,
label,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [displayMonth, setDisplayMonth] = useState(value ? new Date(value) : new Date());
const containerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [displayMonth, setDisplayMonth] = useState(
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 при клике вне компонента
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
const openPicker = () => {
if (disabled) return;
setDisplayMonth(selectedDate ?? new Date());
setOpen(true);
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
const closePicker = () => setOpen(false);
const handleDateSelect = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
onChange(`${year}-${month}-${day}`);
setIsOpen(false);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
onChange(`${y}-${m}-${d}`);
closePicker();
};
// Получаем дни для отображения в календаре
const getDaysInMonth = () => {
const days = useMemo(() => {
const start = startOfMonth(displayMonth);
const end = endOfMonth(displayMonth);
const startDate = startOfWeek(start, { locale: ru });
const endDate = endOfWeek(end, { locale: ru });
return eachDayOfInterval({ start: startDate, end: endDate });
};
return eachDayOfInterval({
start: startOfWeek(start, { locale: ru }),
end: endOfWeek(end, { locale: ru }),
});
}, [displayMonth]);
const days = getDaysInMonth();
const weekDays = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'];
const displayValue = selectedDate
? format(selectedDate, 'd MMMM yyyy', { locale: ru })
: label || 'Выберите дату';
return (
<div ref={containerRef} style={{ position: 'relative', width: '100%' }}>
{/* Input field */}
<>
<button
type="button"
onClick={() => !disabled && setIsOpen(!isOpen)}
onClick={openPicker}
disabled={disabled}
aria-required={required}
style={{
width: '100%',
padding: '12px 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)',
border: `1px solid ${isOpen ? 'var(--md-sys-color-primary)' : 'var(--md-sys-color-outline)'}`,
borderWidth: isOpen ? '2px' : '1px',
border: '1px solid var(--md-sys-color-outline)',
borderRadius: '4px',
fontFamily: 'inherit',
cursor: disabled ? 'not-allowed' : 'pointer',
@ -92,44 +105,46 @@ export const DatePicker: React.FC<DatePickerProps> = ({
textAlign: 'left',
}}
>
<span>
{selectedDate ? format(selectedDate, 'd MMMM yyyy', { locale: ru }) : 'Выберите дату'}
<span>{displayValue}</span>
<span
className="material-symbols-outlined"
style={{ fontSize: 20, opacity: 0.7 }}
>
calendar_today
</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>
{/* Calendar dropdown */}
{isOpen && (
<div style={{
position: 'absolute',
top: 'calc(100% + 4px)',
left: 0,
background: 'var(--md-sys-color-surface)',
border: '1px solid var(--md-sys-color-outline-variant)',
borderRadius: '16px',
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.12)',
zIndex: 1000,
padding: '16px',
minWidth: '320px',
}}>
{/* Header with month/year navigation */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '16px',
}}>
<Dialog
open={open}
onClose={closePicker}
fullWidth
maxWidth="xs"
slotProps={{
paper: {
sx: {
borderRadius: '24px',
overflow: 'visible',
bgcolor: 'var(--md-sys-color-surface)',
},
},
}}
>
<DialogContent sx={{ p: 2 }}>
{/* Month/year header */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 1.5,
}}
>
<button
type="button"
onClick={() => setDisplayMonth(subMonths(displayMonth, 1))}
style={{
width: '32px',
height: '32px',
width: 36,
height: 36,
padding: 0,
background: 'transparent',
border: 'none',
@ -139,30 +154,30 @@ export const DatePicker: React.FC<DatePickerProps> = ({
alignItems: 'center',
justifyContent: 'center',
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">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_left
</span>
</button>
<div style={{
fontSize: '16px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
}}>
<span
style={{
fontSize: 16,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface)',
textTransform: 'capitalize',
}}
>
{format(displayMonth, 'LLLL yyyy', { locale: ru })}
</div>
</span>
<button
type="button"
onClick={() => setDisplayMonth(addMonths(displayMonth, 1))}
style={{
width: '32px',
height: '32px',
width: 36,
height: 36,
padding: 0,
background: 'transparent',
border: 'none',
@ -172,33 +187,32 @@ export const DatePicker: React.FC<DatePickerProps> = ({
alignItems: 'center',
justifyContent: 'center',
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">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
chevron_right
</span>
</button>
</div>
</Box>
{/* Week days header */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '4px',
marginBottom: '8px',
}}>
{weekDays.map(day => (
{/* Weekday headers */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2,
marginBottom: 4,
}}
>
{weekDays.map((day) => (
<div
key={day}
style={{
textAlign: 'center',
fontSize: '12px',
fontWeight: '500',
fontSize: 12,
fontWeight: 500,
color: 'var(--md-sys-color-on-surface-variant)',
padding: '8px 0',
padding: '6px 0',
}}
>
{day}
@ -206,56 +220,51 @@ export const DatePicker: React.FC<DatePickerProps> = ({
))}
</div>
{/* Calendar days grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: '4px',
}}>
{days.map((day, index) => {
{/* Calendar days */}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(7, 1fr)',
gap: 2,
}}
>
{days.map((day, idx) => {
const isSelected = selectedDate && isSameDay(day, selectedDate);
const isCurrentMonth = isSameMonth(day, displayMonth);
const isCurrent = isSameMonth(day, displayMonth);
const isToday = isSameDay(day, new Date());
return (
<button
key={index}
key={idx}
type="button"
onClick={() => handleDateSelect(day)}
style={{
width: '40px',
height: '40px',
width: '100%',
aspectRatio: '1',
maxWidth: 40,
margin: '0 auto',
padding: 0,
background: isSelected
? 'var(--md-sys-color-primary)'
: 'transparent',
border: isToday && !isSelected
? '1px solid var(--md-sys-color-primary)'
: 'none',
border:
isToday && !isSelected
? '1px solid var(--md-sys-color-primary)'
: 'none',
borderRadius: '50%',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '14px',
fontWeight: isSelected ? '500' : '400',
fontSize: 14,
fontWeight: isSelected ? 600 : 400,
color: isSelected
? 'var(--md-sys-color-on-primary)'
: isCurrentMonth
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
opacity: isCurrentMonth ? 1 : 0.4,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isSelected) {
e.currentTarget.style.background = 'var(--md-sys-color-surface-variant)';
}
}}
onMouseLeave={(e) => {
if (!isSelected) {
e.currentTarget.style.background = 'transparent';
}
: isCurrent
? 'var(--md-sys-color-on-surface)'
: 'var(--md-sys-color-on-surface-variant)',
opacity: isCurrent ? 1 : 0.35,
transition: 'background 0.15s',
}}
>
{format(day, 'd')}
@ -264,36 +273,44 @@ export const DatePicker: React.FC<DatePickerProps> = ({
})}
</div>
{/* Today button */}
<div style={{
marginTop: '16px',
paddingTop: '16px',
borderTop: '1px solid var(--md-sys-color-outline-variant)',
display: 'flex',
justifyContent: 'center',
}}>
<button
type="button"
{/* Actions */}
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mt: 2,
pt: 1.5,
borderTop: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<Button
onClick={() => handleDateSelect(new Date())}
style={{
padding: '8px 16px',
background: 'transparent',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500',
variant="text"
sx={{
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>
</div>
</div>
)}
</div>
</Button>
<Button
onClick={closePicker}
variant="text"
sx={{
color: 'var(--md-sys-color-on-surface-variant)',
textTransform: 'none',
fontWeight: 500,
fontSize: 14,
}}
>
Отмена
</Button>
</Box>
</DialogContent>
</Dialog>
</>
);
};

View File

@ -1,252 +1,251 @@
/**
* Dashboard для студента (роль client) или для выбранного ребёнка (роль parent)
*/
'use client';
import React, { useState, useEffect } from 'react';
import { getClientDashboard, getChildDashboard, DashboardStats } from '@/api/dashboard';
import { StatCard } from './StatCard';
import { LessonCard } from './LessonCard';
import { HomeworkCard } from './HomeworkCard';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export interface ClientDashboardProps {
/** Для родителя: id выбранного ребёнка (user_id) — данные загружаются как для этого ребёнка */
childId?: string | null;
/** Для родителя: имя ребёнка для приветствия */
childName?: string | null;
}
export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) => {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isParentView = Boolean(childId);
useEffect(() => {
loadDashboard();
}, [childId]);
const loadDashboard = async () => {
try {
setLoading(true);
setError(null);
const data = isParentView && childId
? await getChildDashboard(childId)
: await getClientDashboard();
setStats(data);
} catch (err: any) {
console.error('Error loading dashboard:', err);
setError(err.message || 'Ошибка загрузки данных');
} finally {
setLoading(false);
}
};
if (error && !stats) {
return (
<div style={{
padding: '24px',
textAlign: 'center',
color: 'var(--md-sys-color-error)'
}}>
<p>{error}</p>
<button
onClick={loadDashboard}
style={{
marginTop: '16px',
padding: '12px 24px',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
Попробовать снова
</button>
</div>
);
}
return (
<div style={{
width: '100%',
maxWidth: '100%',
padding: '16px',
minHeight: '100vh'
}}>
{/* Статистика студента */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
<StatCard
title="Занятий всего"
value={loading ? '—' : (stats?.total_lessons || 0)}
loading={loading}
icon={
<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>
<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>
}
/>
<StatCard
title="Пройдено"
value={loading ? '—' : (stats?.completed_lessons || 0)}
loading={loading}
icon={
<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>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
}
/>
<StatCard
title="ДЗ к выполнению"
value={loading ? '—' : (stats?.homework_pending || 0)}
loading={loading}
icon={
<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>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
}
/>
<StatCard
title="Средняя оценка"
value={loading ? '—' : (stats?.average_grade != null ? String(parseFloat(Number(stats.average_grade).toFixed(2))) : '-')}
loading={loading}
icon={
<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>
</svg>
}
/>
</div>
{/* Следующее занятие */}
{stats?.next_lesson && (
<div style={{
background: 'var(--md-sys-color-surface)',
borderRadius: '20px',
padding: '24px',
border: '1px solid var(--md-sys-color-outline-variant)',
marginBottom: '24px',
borderLeft: '4px solid var(--md-sys-color-primary)'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
margin: '0 0 16px 0'
}}>
Ближайшее занятие
</h3>
<LessonCard lesson={stats.next_lesson} showMentor />
</div>
)}
{/* Домашние задания и расписание */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
{/* Домашние задания */}
<div style={{
background: 'var(--md-sys-color-surface)',
borderRadius: '20px',
padding: '24px',
border: '1px solid var(--md-sys-color-outline-variant)'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
margin: '0 0 20px 0'
}}>
Ваши домашние задания
</h3>
{loading ? (
<LoadingSpinner size="medium" />
) : stats?.recent_homework && stats.recent_homework.length > 0 ? (
<div>
{stats.recent_homework.slice(0, 3).map((homework) => (
<HomeworkCard key={homework.id} homework={homework} />
))}
</div>
) : (
<div style={{
textAlign: 'center',
padding: '32px',
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 }}>
<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>
</svg>
<p style={{ margin: 0 }}>Нет домашних заданий</p>
</div>
)}
</div>
{/* Ближайшие занятия */}
<div style={{
background: 'var(--md-sys-color-surface)',
borderRadius: '20px',
padding: '24px',
border: '1px solid var(--md-sys-color-outline-variant)'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
margin: '0 0 20px 0'
}}>
Ваши занятия
</h3>
{loading ? (
<LoadingSpinner size="medium" />
) : stats?.upcoming_lessons && stats.upcoming_lessons.length > 0 ? (
<div>
{stats.upcoming_lessons.slice(0, 3).map((lesson) => (
<LessonCard key={lesson.id} lesson={lesson} showMentor />
))}
</div>
) : (
<div style={{
textAlign: 'center',
padding: '32px',
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 }}>
<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>
<p style={{ margin: 0 }}>Нет запланированных занятий</p>
</div>
)}
</div>
</div>
</div>
);
};
/**
* Dashboard для студента (роль client) или для выбранного ребёнка (роль parent)
*/
'use client';
import React, { useState, useEffect } from 'react';
import { getClientDashboard, getChildDashboard, DashboardStats } from '@/api/dashboard';
import { StatCard } from './StatCard';
import { LessonCard } from './LessonCard';
import { HomeworkCard } from './HomeworkCard';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export interface ClientDashboardProps {
/** Для родителя: id выбранного ребёнка (user_id) — данные загружаются как для этого ребёнка */
childId?: string | null;
/** Для родителя: имя ребёнка для приветствия */
childName?: string | null;
}
export const ClientDashboard: React.FC<ClientDashboardProps> = ({ childId }) => {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isParentView = Boolean(childId);
useEffect(() => {
loadDashboard();
}, [childId]);
const loadDashboard = async () => {
try {
setLoading(true);
setError(null);
const data = isParentView && childId
? await getChildDashboard(childId)
: await getClientDashboard();
setStats(data);
} catch (err: any) {
console.error('Error loading dashboard:', err);
setError(err.message || 'Ошибка загрузки данных');
} finally {
setLoading(false);
}
};
if (error && !stats) {
return (
<div style={{
padding: '24px',
textAlign: 'center',
color: 'var(--md-sys-color-error)'
}}>
<p>{error}</p>
<button
onClick={loadDashboard}
style={{
marginTop: '16px',
padding: '12px 24px',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
Попробовать снова
</button>
</div>
);
}
return (
<div style={{
width: '100%',
maxWidth: '100%',
padding: '16px',
}}>
{/* Статистика студента */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
<StatCard
title="Занятий всего"
value={loading ? '—' : (stats?.total_lessons || 0)}
loading={loading}
icon={
<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>
<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>
}
/>
<StatCard
title="Пройдено"
value={loading ? '—' : (stats?.completed_lessons || 0)}
loading={loading}
icon={
<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>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
}
/>
<StatCard
title="ДЗ к выполнению"
value={loading ? '—' : (stats?.homework_pending || 0)}
loading={loading}
icon={
<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>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="16" y1="13" x2="8" y2="13"></line>
<line x1="16" y1="17" x2="8" y2="17"></line>
</svg>
}
/>
<StatCard
title="Средняя оценка"
value={loading ? '—' : (stats?.average_grade != null ? String(parseFloat(Number(stats.average_grade).toFixed(2))) : '-')}
loading={loading}
icon={
<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>
</svg>
}
/>
</div>
{/* Следующее занятие */}
{stats?.next_lesson && (
<div style={{
background: 'var(--md-sys-color-surface)',
borderRadius: '20px',
padding: '24px',
border: '1px solid var(--md-sys-color-outline-variant)',
marginBottom: '24px',
borderLeft: '4px solid var(--md-sys-color-primary)'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
margin: '0 0 16px 0'
}}>
Ближайшее занятие
</h3>
<LessonCard lesson={stats.next_lesson} showMentor />
</div>
)}
{/* Домашние задания и расписание */}
<div className="client-dashboard-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(400px, 100%), 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
{/* Домашние задания */}
<div style={{
background: 'var(--md-sys-color-surface)',
borderRadius: '20px',
padding: '24px',
border: '1px solid var(--md-sys-color-outline-variant)'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
margin: '0 0 20px 0'
}}>
Ваши домашние задания
</h3>
{loading ? (
<LoadingSpinner size="medium" />
) : stats?.recent_homework && stats.recent_homework.length > 0 ? (
<div>
{stats.recent_homework.slice(0, 3).map((homework) => (
<HomeworkCard key={homework.id} homework={homework} />
))}
</div>
) : (
<div style={{
textAlign: 'center',
padding: '32px',
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 }}>
<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>
</svg>
<p style={{ margin: 0 }}>Нет домашних заданий</p>
</div>
)}
</div>
{/* Ближайшие занятия */}
<div style={{
background: 'var(--md-sys-color-surface)',
borderRadius: '20px',
padding: '24px',
border: '1px solid var(--md-sys-color-outline-variant)'
}}>
<h3 style={{
fontSize: '20px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
margin: '0 0 20px 0'
}}>
Ваши занятия
</h3>
{loading ? (
<LoadingSpinner size="medium" />
) : stats?.upcoming_lessons && stats.upcoming_lessons.length > 0 ? (
<div>
{stats.upcoming_lessons.slice(0, 3).map((lesson) => (
<LessonCard key={lesson.id} lesson={lesson} showMentor />
))}
</div>
) : (
<div style={{
textAlign: 'center',
padding: '32px',
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 }}>
<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>
<p style={{ margin: 0 }}>Нет запланированных занятий</p>
</div>
)}
</div>
</div>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -1,257 +1,256 @@
/**
* Dashboard для родителя
*/
'use client';
import React, { useState, useEffect } from 'react';
import { getParentDashboard, DashboardStats } from '@/api/dashboard';
import { StatCard } from './StatCard';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export const ParentDashboard: React.FC = () => {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadDashboard();
}, []);
const loadDashboard = async () => {
try {
setLoading(true);
setError(null);
const data = await getParentDashboard();
setStats(data);
} catch (err: any) {
console.error('Error loading dashboard:', err);
setError(err.message || 'Ошибка загрузки данных');
} finally {
setLoading(false);
}
};
if (error && !stats) {
return (
<div style={{
padding: '24px',
textAlign: 'center',
color: 'var(--md-sys-color-error)'
}}>
<p>{error}</p>
<button
onClick={loadDashboard}
style={{
marginTop: '16px',
padding: '12px 24px',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
Попробовать снова
</button>
</div>
);
}
return (
<div style={{
width: '100%',
maxWidth: '100%',
padding: '16px',
background: 'var(--md-sys-color-background)',
minHeight: '100vh'
}}>
{/* Приветствие */}
<div style={{
background: 'linear-gradient(135deg, var(--md-sys-color-primary) 0%, var(--md-sys-color-tertiary) 100%)',
borderRadius: '20px',
padding: '32px',
marginBottom: '24px',
color: 'var(--md-sys-color-on-primary)'
}}>
<h1 style={{
fontSize: '28px',
fontWeight: '500',
margin: '0 0 8px 0'
}}>
Добро пожаловать! 👨👩👧👦
</h1>
<p style={{
fontSize: '16px',
margin: 0,
opacity: 0.9
}}>
Отслеживайте прогресс ваших детей и их успехи в обучении
</p>
</div>
{/* Статистика по детям */}
{stats?.children_stats && stats.children_stats.length > 0 && (
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
{stats.children_stats.map((child) => (
<div
key={child.id}
style={{
background: 'var(--md-sys-color-surface)',
borderRadius: '20px',
padding: '24px',
border: '1px solid var(--md-sys-color-outline-variant)'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '20px'
}}>
{child.avatar_url ? (
<img
src={child.avatar_url}
alt={child.name}
style={{
width: '56px',
height: '56px',
borderRadius: '50%',
objectFit: 'cover'
}}
/>
) : (
<div style={{
width: '56px',
height: '56px',
borderRadius: '50%',
background: 'var(--md-sys-color-primary-container)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-primary-container)',
fontSize: '24px',
fontWeight: '500'
}}>
{child.name.charAt(0).toUpperCase()}
</div>
)}
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
margin: '0 0 4px 0'
}}>
{child.name}
</h3>
<p style={{
fontSize: '14px',
color: 'var(--md-sys-color-on-surface-variant)',
margin: 0
}}>
{child.completed_lessons} / {child.total_lessons} занятий
</p>
</div>
</div>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '4px',
fontSize: '12px',
color: 'var(--md-sys-color-on-surface-variant)'
}}>
<span>Прогресс занятий</span>
<span>{Math.round((child.completed_lessons / child.total_lessons) * 100)}%</span>
</div>
<div style={{
width: '100%',
height: '6px',
background: 'var(--md-sys-color-surface-variant)',
borderRadius: '3px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${(child.completed_lessons / child.total_lessons) * 100}%`,
background: 'var(--md-sys-color-primary)',
transition: 'width 0.3s ease'
}}></div>
</div>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '14px'
}}>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
Средняя оценка:
</span>
<span style={{
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)'
}}>
{child.average_grade != null ? String(parseFloat(Number(child.average_grade).toFixed(2))) : '—'}
</span>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '14px'
}}>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
Домашних заданий:
</span>
<span style={{
fontWeight: '500',
color: child.homework_pending > 0 ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-tertiary)'
}}>
{child.homework_pending}
</span>
</div>
</div>
</div>
))}
</div>
)}
{/* Общая статистика */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
<StatCard
title="Всего детей"
value={loading ? '—' : (stats?.children_count || 0)}
loading={loading}
icon={
<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>
<circle cx="9" cy="7" r="4"></circle>
<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>
</svg>
}
/>
</div>
</div>
);
};
/**
* Dashboard для родителя
*/
'use client';
import React, { useState, useEffect } from 'react';
import { getParentDashboard, DashboardStats } from '@/api/dashboard';
import { StatCard } from './StatCard';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export const ParentDashboard: React.FC = () => {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadDashboard();
}, []);
const loadDashboard = async () => {
try {
setLoading(true);
setError(null);
const data = await getParentDashboard();
setStats(data);
} catch (err: any) {
console.error('Error loading dashboard:', err);
setError(err.message || 'Ошибка загрузки данных');
} finally {
setLoading(false);
}
};
if (error && !stats) {
return (
<div style={{
padding: '24px',
textAlign: 'center',
color: 'var(--md-sys-color-error)'
}}>
<p>{error}</p>
<button
onClick={loadDashboard}
style={{
marginTop: '16px',
padding: '12px 24px',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
border: 'none',
borderRadius: '20px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
}}
>
Попробовать снова
</button>
</div>
);
}
return (
<div style={{
width: '100%',
maxWidth: '100%',
padding: '16px',
background: 'var(--md-sys-color-background)',
}}>
{/* Приветствие */}
<div style={{
background: 'linear-gradient(135deg, var(--md-sys-color-primary) 0%, var(--md-sys-color-tertiary) 100%)',
borderRadius: '20px',
padding: '32px',
marginBottom: '24px',
color: 'var(--md-sys-color-on-primary)'
}}>
<h1 className="parent-dashboard-title" style={{
fontSize: '28px',
fontWeight: '500',
margin: '0 0 8px 0'
}}>
Добро пожаловать! 👨👩👧👦
</h1>
<p style={{
fontSize: '16px',
margin: 0,
opacity: 0.9
}}>
Отслеживайте прогресс ваших детей и их успехи в обучении
</p>
</div>
{/* Статистика по детям */}
{stats?.children_stats && stats.children_stats.length > 0 && (
<div className="parent-children-grid" style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(300px, 100%), 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
{stats.children_stats.map((child) => (
<div
key={child.id}
style={{
background: 'var(--md-sys-color-surface)',
borderRadius: '20px',
padding: '24px',
border: '1px solid var(--md-sys-color-outline-variant)'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '16px',
marginBottom: '20px'
}}>
{child.avatar_url ? (
<img
src={child.avatar_url}
alt={child.name}
style={{
width: '56px',
height: '56px',
borderRadius: '50%',
objectFit: 'cover'
}}
/>
) : (
<div style={{
width: '56px',
height: '56px',
borderRadius: '50%',
background: 'var(--md-sys-color-primary-container)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--md-sys-color-on-primary-container)',
fontSize: '24px',
fontWeight: '500'
}}>
{child.name.charAt(0).toUpperCase()}
</div>
)}
<div>
<h3 style={{
fontSize: '18px',
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)',
margin: '0 0 4px 0'
}}>
{child.name}
</h3>
<p style={{
fontSize: '14px',
color: 'var(--md-sys-color-on-surface-variant)',
margin: 0
}}>
{child.completed_lessons} / {child.total_lessons} занятий
</p>
</div>
</div>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}>
<div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '4px',
fontSize: '12px',
color: 'var(--md-sys-color-on-surface-variant)'
}}>
<span>Прогресс занятий</span>
<span>{Math.round((child.completed_lessons / child.total_lessons) * 100)}%</span>
</div>
<div style={{
width: '100%',
height: '6px',
background: 'var(--md-sys-color-surface-variant)',
borderRadius: '3px',
overflow: 'hidden'
}}>
<div style={{
height: '100%',
width: `${(child.completed_lessons / child.total_lessons) * 100}%`,
background: 'var(--md-sys-color-primary)',
transition: 'width 0.3s ease'
}}></div>
</div>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '14px'
}}>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
Средняя оценка:
</span>
<span style={{
fontWeight: '500',
color: 'var(--md-sys-color-on-surface)'
}}>
{child.average_grade != null ? String(parseFloat(Number(child.average_grade).toFixed(2))) : '—'}
</span>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
fontSize: '14px'
}}>
<span style={{ color: 'var(--md-sys-color-on-surface-variant)' }}>
Домашних заданий:
</span>
<span style={{
fontWeight: '500',
color: child.homework_pending > 0 ? 'var(--md-sys-color-error)' : 'var(--md-sys-color-tertiary)'
}}>
{child.homework_pending}
</span>
</div>
</div>
</div>
))}
</div>
)}
{/* Общая статистика */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
gap: '16px',
marginBottom: '24px'
}}>
<StatCard
title="Всего детей"
value={loading ? '—' : (stats?.children_count || 0)}
loading={loading}
icon={
<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>
<circle cx="9" cy="7" r="4"></circle>
<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>
</svg>
}
/>
</div>
</div>
);
};

View File

@ -1,149 +1,150 @@
/**
* Секция «Динамика доходов» для дашборда ментора (iOS 26).
* Использует переиспользуемые Panel, SectionHeader, SegmentedControl.
*/
'use client';
import React from 'react';
import { MentorIncomeResponse } from '@/api/dashboard';
import { Panel, SectionHeader, SegmentedControl } from '../ui';
import { RevenueChart } from '../RevenueChart';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export type IncomePeriod = 'day' | 'week' | 'month';
export interface IncomeSectionProps {
data: MentorIncomeResponse | null;
period: IncomePeriod;
onPeriodChange: (p: IncomePeriod) => void;
loading: boolean;
}
const PERIOD_OPTIONS = [
{ value: 'day' as const, label: 'День' },
{ value: 'week' as const, label: 'Неделя' },
{ value: 'month' as const, label: 'Месяц' },
];
export const IncomeSection: React.FC<IncomeSectionProps> = ({
data,
period,
onPeriodChange,
loading,
}) => {
const totalIncome = Number(data?.summary?.total_income ?? 0);
const totalLessons = Number(data?.summary?.total_lessons ?? 0);
const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0);
return (
<Panel padding="md">
<SectionHeader
title="Динамика доходов"
trailing={
<SegmentedControl
options={PERIOD_OPTIONS}
value={period}
onChange={onPeriodChange}
disabled={loading}
/>
}
/>
{loading && !data ? (
<LoadingSpinner size="medium" />
) : (
<>
<RevenueChart
data={data?.chart_data ?? []}
loading={loading}
period={period}
/>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 'var(--ios26-spacing)',
paddingTop: 'var(--ios26-spacing-md)',
borderTop: '1px solid var(--ios26-list-divider)',
minHeight: 72,
}}
>
<div>
<p
style={{
fontSize: 16,
color: 'var(--md-sys-color-on-surface-variant)',
margin: '0 0 4px 0',
}}
>
Всего доход
</p>
<p
style={{
fontSize: 21,
fontWeight: 600,
color: 'var(--md-sys-color-primary)',
margin: 0,
letterSpacing: '-0.02em',
opacity: data?.summary ? 1 : 0.6,
}}
>
{data?.summary
? `${Math.round(totalIncome).toLocaleString('ru-RU')}`
: '—'}
</p>
</div>
<div>
<p
style={{
fontSize: 16,
color: 'var(--md-sys-color-on-surface-variant)',
margin: '0 0 4px 0',
}}
>
Занятий
</p>
<p
style={{
fontSize: 21,
fontWeight: 600,
color: 'var(--md-sys-color-primary)',
margin: 0,
letterSpacing: '-0.02em',
opacity: data?.summary ? 1 : 0.6,
}}
>
{data?.summary ? totalLessons : '—'}
</p>
</div>
<div>
<p
style={{
fontSize: 16,
color: 'var(--md-sys-color-on-surface-variant)',
margin: '0 0 4px 0',
}}
>
Средняя цена
</p>
<p
style={{
fontSize: 21,
fontWeight: 600,
color: 'var(--md-sys-color-primary)',
margin: 0,
letterSpacing: '-0.02em',
opacity: data?.summary ? 1 : 0.6,
}}
>
{data?.summary
? `${Math.round(averageLessonPrice).toLocaleString('ru-RU')}`
: '—'}
</p>
</div>
</div>
</>
)}
</Panel>
);
};
/**
* Секция «Динамика доходов» для дашборда ментора (iOS 26).
* Использует переиспользуемые Panel, SectionHeader, SegmentedControl.
*/
'use client';
import React from 'react';
import { MentorIncomeResponse } from '@/api/dashboard';
import { Panel, SectionHeader, SegmentedControl } from '../ui';
import { RevenueChart } from '../RevenueChart';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
export type IncomePeriod = 'day' | 'week' | 'month';
export interface IncomeSectionProps {
data: MentorIncomeResponse | null;
period: IncomePeriod;
onPeriodChange: (p: IncomePeriod) => void;
loading: boolean;
}
const PERIOD_OPTIONS = [
{ value: 'day' as const, label: 'День' },
{ value: 'week' as const, label: 'Неделя' },
{ value: 'month' as const, label: 'Месяц' },
];
export const IncomeSection: React.FC<IncomeSectionProps> = ({
data,
period,
onPeriodChange,
loading,
}) => {
const totalIncome = Number(data?.summary?.total_income ?? 0);
const totalLessons = Number(data?.summary?.total_lessons ?? 0);
const averageLessonPrice = Number(data?.summary?.average_lesson_price ?? 0);
return (
<Panel padding="md">
<SectionHeader
title="Динамика доходов"
trailing={
<SegmentedControl
options={PERIOD_OPTIONS}
value={period}
onChange={onPeriodChange}
disabled={loading}
/>
}
/>
{loading && !data ? (
<LoadingSpinner size="medium" />
) : (
<>
<RevenueChart
data={data?.chart_data ?? []}
loading={loading}
period={period}
/>
<div
className="income-stats-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 'var(--ios26-spacing)',
paddingTop: 'var(--ios26-spacing-md)',
borderTop: '1px solid var(--ios26-list-divider)',
minHeight: 72,
}}
>
<div>
<p
style={{
fontSize: 16,
color: 'var(--md-sys-color-on-surface-variant)',
margin: '0 0 4px 0',
}}
>
Всего доход
</p>
<p
style={{
fontSize: 21,
fontWeight: 600,
color: 'var(--md-sys-color-primary)',
margin: 0,
letterSpacing: '-0.02em',
opacity: data?.summary ? 1 : 0.6,
}}
>
{data?.summary
? `${Math.round(totalIncome).toLocaleString('ru-RU')}`
: '—'}
</p>
</div>
<div>
<p
style={{
fontSize: 16,
color: 'var(--md-sys-color-on-surface-variant)',
margin: '0 0 4px 0',
}}
>
Занятий
</p>
<p
style={{
fontSize: 21,
fontWeight: 600,
color: 'var(--md-sys-color-primary)',
margin: 0,
letterSpacing: '-0.02em',
opacity: data?.summary ? 1 : 0.6,
}}
>
{data?.summary ? totalLessons : '—'}
</p>
</div>
<div>
<p
style={{
fontSize: 16,
color: 'var(--md-sys-color-on-surface-variant)',
margin: '0 0 4px 0',
}}
>
Средняя цена
</p>
<p
style={{
fontSize: 21,
fontWeight: 600,
color: 'var(--md-sys-color-primary)',
margin: 0,
letterSpacing: '-0.02em',
opacity: data?.summary ? 1 : 0.6,
}}
>
{data?.summary
? `${Math.round(averageLessonPrice).toLocaleString('ru-RU')}`
: '—'}
</p>
</div>
</div>
</>
)}
</Panel>
);
};

View File

@ -1,257 +1,258 @@
/**
* Секция «Последние сданные ДЗ» для дашборда ментора (iOS 26).
*/
'use client';
import React, { useMemo, useState } from 'react';
import { MentorDashboardResponse } from '@/api/dashboard';
import { Panel, SectionHeader, FlipCard } from '../ui';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { getHomeworkSubmission, type HomeworkSubmission } from '@/api/homework';
export interface RecentSubmissionsSectionProps {
data: MentorDashboardResponse | null;
loading: boolean;
}
const formatDateTime = (dateTimeStr: string | null): string => {
if (!dateTimeStr) return '—';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '—';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) {
const diffMins = Math.floor(diffMs / (1000 * 60));
return diffMins <= 1 ? 'только что' : `${diffMins} мин назад`;
} else if (diffHours < 24) {
return `${diffHours} ч назад`;
} else if (diffDays === 1) {
return 'Вчера';
} else if (diffDays < 7) {
return `${diffDays} дн назад`;
} else {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
});
}
} catch {
return '—';
}
};
const getStatusColor = (status: string): string => {
switch (status) {
case 'graded':
return 'var(--md-sys-color-tertiary)';
case 'returned':
return 'var(--md-sys-color-error)';
case 'submitted':
return 'var(--md-sys-color-on-surface-variant)';
default:
return 'var(--md-sys-color-on-surface-variant)';
}
};
const getStatusLabel = (status: string): string => {
switch (status) {
case 'graded':
return 'Проверено';
case 'returned':
return 'На доработке';
case 'submitted':
return 'Сдано';
default:
return status;
}
};
export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> = ({
data,
loading,
}) => {
const submissions = data?.recent_submissions?.slice(0, 4) || [];
const [flipped, setFlipped] = useState(false);
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
const [details, setDetails] = useState<HomeworkSubmission | null>(null);
const [loadingDetails, setLoadingDetails] = useState(false);
const selectedPreview = useMemo(
() => submissions.find((s) => s.id === selectedSubmissionId) || null,
[submissions, selectedSubmissionId],
);
const formatFullDateTime = (dateTimeStr: string | null): string => {
if (!dateTimeStr) return '—';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '—';
}
};
const openSubmissionDetails = async (submissionId: string) => {
setSelectedSubmissionId(submissionId);
setFlipped(true);
setLoadingDetails(true);
setDetails(null);
try {
const full = await getHomeworkSubmission(submissionId);
setDetails(full);
} catch (error) {
console.error('Ошибка загрузки решения ДЗ:', error);
} finally {
setLoadingDetails(false);
}
};
return (
<FlipCard
flipped={flipped}
onFlippedChange={setFlipped}
front={
<Panel padding="md">
<SectionHeader title="Последние сданные ДЗ" />
{loading && !data ? (
<LoadingSpinner size="medium" />
) : submissions.length === 0 ? (
<div
style={{
padding: 'var(--ios26-spacing-md) 0',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Нет сданных ДЗ
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: 'var(--ios26-spacing-sm)',
}}
onClick={(e) => e.stopPropagation()}
>
{submissions.map((submission) => {
const studentName = submission.student?.first_name && submission.student?.last_name
? `${submission.student.first_name} ${submission.student.last_name}`.trim()
: submission.student?.name || 'Студент';
return (
<button
key={submission.id}
type="button"
onClick={() => openSubmissionDetails(submission.id)}
className="ios26-lesson-preview"
>
<div className="ios26-lesson-avatar">
{submission.student?.avatar ? (
<img src={submission.student.avatar} alt={studentName} />
) : (
<span>
{(studentName || 'С')[0]?.toUpperCase()}
</span>
)}
</div>
<div className="ios26-lesson-text">
<p className="ios26-lesson-subject">
{submission.subject || 'Предмет не указан'}
</p>
<p className="ios26-lesson-student">
{studentName}
</p>
{submission.score != null && (
<p className="ios26-lesson-datetime">
{submission.score}/5 {getStatusLabel(submission.status)}
</p>
)}
{submission.score == null && (
<p className="ios26-lesson-datetime">
{getStatusLabel(submission.status)}
</p>
)}
</div>
</button>
);
})}
</div>
)}
</Panel>
}
back={
<Panel padding="md">
<SectionHeader
title="Детали ДЗ"
trailing={
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setFlipped(false);
}}
className="ios26-back-button"
>
Назад
</button>
}
/>
<div onClick={(e) => e.stopPropagation()}>
{loadingDetails ? (
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
Загрузка...
</div>
) : details ? (
<div>
<p style={{ fontSize: 18, fontWeight: 700, margin: '0 0 8px 0' }}>
{details.homework?.title || selectedPreview?.homework?.title || 'ДЗ'}
</p>
{details.homework?.description && (
<p style={{ fontSize: 14, margin: '0 0 10px 0', lineHeight: 1.5, color: 'var(--md-sys-color-on-surface-variant)' }}>
{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>
<p style={{ fontSize: 14, margin: '0 0 6px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
<strong>Сдано:</strong> {formatFullDateTime(details.submitted_at)}
</p>
{details.score != null && (
<p style={{ fontSize: 14, fontWeight: 600, margin: '0 0 6px 0', color: getStatusColor(details.status) }}>
Оценка: {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)' }}>
<strong>Отзыв:</strong> {details.feedback}
</p>
)}
</div>
) : (
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
Не удалось загрузить детали
</div>
)}
</div>
</Panel>
}
/>
);
};
/**
* Секция «Последние сданные ДЗ» для дашборда ментора (iOS 26).
*/
'use client';
import React, { useMemo, useState } from 'react';
import { MentorDashboardResponse } from '@/api/dashboard';
import { Panel, SectionHeader, FlipCard } from '../ui';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { getHomeworkSubmission, type HomeworkSubmission } from '@/api/homework';
export interface RecentSubmissionsSectionProps {
data: MentorDashboardResponse | null;
loading: boolean;
}
const formatDateTime = (dateTimeStr: string | null): string => {
if (!dateTimeStr) return '—';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '—';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) {
const diffMins = Math.floor(diffMs / (1000 * 60));
return diffMins <= 1 ? 'только что' : `${diffMins} мин назад`;
} else if (diffHours < 24) {
return `${diffHours} ч назад`;
} else if (diffDays === 1) {
return 'Вчера';
} else if (diffDays < 7) {
return `${diffDays} дн назад`;
} else {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
});
}
} catch {
return '—';
}
};
const getStatusColor = (status: string): string => {
switch (status) {
case 'graded':
return 'var(--md-sys-color-tertiary)';
case 'returned':
return 'var(--md-sys-color-error)';
case 'submitted':
return 'var(--md-sys-color-on-surface-variant)';
default:
return 'var(--md-sys-color-on-surface-variant)';
}
};
const getStatusLabel = (status: string): string => {
switch (status) {
case 'graded':
return 'Проверено';
case 'returned':
return 'На доработке';
case 'submitted':
return 'Сдано';
default:
return status;
}
};
export const RecentSubmissionsSection: React.FC<RecentSubmissionsSectionProps> = ({
data,
loading,
}) => {
const submissions = data?.recent_submissions?.slice(0, 4) || [];
const [flipped, setFlipped] = useState(false);
const [selectedSubmissionId, setSelectedSubmissionId] = useState<string | null>(null);
const [details, setDetails] = useState<HomeworkSubmission | null>(null);
const [loadingDetails, setLoadingDetails] = useState(false);
const selectedPreview = useMemo(
() => submissions.find((s) => s.id === selectedSubmissionId) || null,
[submissions, selectedSubmissionId],
);
const formatFullDateTime = (dateTimeStr: string | null): string => {
if (!dateTimeStr) return '—';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '—';
}
};
const openSubmissionDetails = async (submissionId: string) => {
setSelectedSubmissionId(submissionId);
setFlipped(true);
setLoadingDetails(true);
setDetails(null);
try {
const full = await getHomeworkSubmission(submissionId);
setDetails(full);
} catch (error) {
console.error('Ошибка загрузки решения ДЗ:', error);
} finally {
setLoadingDetails(false);
}
};
return (
<FlipCard
flipped={flipped}
onFlippedChange={setFlipped}
front={
<Panel padding="md">
<SectionHeader title="Последние сданные ДЗ" />
{loading && !data ? (
<LoadingSpinner size="medium" />
) : submissions.length === 0 ? (
<div
style={{
padding: 'var(--ios26-spacing-md) 0',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Нет сданных ДЗ
</div>
) : (
<div
className="recent-submissions-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: 'var(--ios26-spacing-sm)',
}}
onClick={(e) => e.stopPropagation()}
>
{submissions.map((submission) => {
const studentName = submission.student?.first_name && submission.student?.last_name
? `${submission.student.first_name} ${submission.student.last_name}`.trim()
: submission.student?.name || 'Студент';
return (
<button
key={submission.id}
type="button"
onClick={() => openSubmissionDetails(submission.id)}
className="ios26-lesson-preview"
>
<div className="ios26-lesson-avatar">
{submission.student?.avatar ? (
<img src={submission.student.avatar} alt={studentName} />
) : (
<span>
{(studentName || 'С')[0]?.toUpperCase()}
</span>
)}
</div>
<div className="ios26-lesson-text">
<p className="ios26-lesson-subject">
{submission.subject || 'Предмет не указан'}
</p>
<p className="ios26-lesson-student">
{studentName}
</p>
{submission.score != null && (
<p className="ios26-lesson-datetime">
{submission.score}/5 {getStatusLabel(submission.status)}
</p>
)}
{submission.score == null && (
<p className="ios26-lesson-datetime">
{getStatusLabel(submission.status)}
</p>
)}
</div>
</button>
);
})}
</div>
)}
</Panel>
}
back={
<Panel padding="md">
<SectionHeader
title="Детали ДЗ"
trailing={
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setFlipped(false);
}}
className="ios26-back-button"
>
Назад
</button>
}
/>
<div onClick={(e) => e.stopPropagation()}>
{loadingDetails ? (
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
Загрузка...
</div>
) : details ? (
<div>
<p style={{ fontSize: 18, fontWeight: 700, margin: '0 0 8px 0' }}>
{details.homework?.title || selectedPreview?.homework?.title || 'ДЗ'}
</p>
{details.homework?.description && (
<p style={{ fontSize: 14, margin: '0 0 10px 0', lineHeight: 1.5, color: 'var(--md-sys-color-on-surface-variant)' }}>
{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>
<p style={{ fontSize: 14, margin: '0 0 6px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
<strong>Сдано:</strong> {formatFullDateTime(details.submitted_at)}
</p>
{details.score != null && (
<p style={{ fontSize: 14, fontWeight: 600, margin: '0 0 6px 0', color: getStatusColor(details.status) }}>
Оценка: {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)' }}>
<strong>Отзыв:</strong> {details.feedback}
</p>
)}
</div>
) : (
<div style={{ padding: 'var(--ios26-spacing-md) 0', textAlign: 'center', color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
Не удалось загрузить детали
</div>
)}
</div>
</Panel>
}
/>
);
};

View File

@ -1,248 +1,249 @@
/**
* Секция «Ближайшие занятия» для дашборда ментора (iOS 26).
*/
'use client';
import React, { useMemo, useState } from 'react';
import { MentorDashboardResponse } from '@/api/dashboard';
import { Panel, SectionHeader, FlipCard } from '../ui';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { getLesson, type Lesson } from '@/api/schedule';
export interface UpcomingLessonsSectionProps {
data: MentorDashboardResponse | null;
loading: boolean;
}
const formatDateTime = (dateTimeStr: string | null): string => {
if (!dateTimeStr) return '—';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '—';
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const isToday = date.toDateString() === today.toDateString();
const isTomorrow = date.toDateString() === tomorrow.toDateString();
const timeStr = date.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
});
if (isToday) {
return `Сегодня, ${timeStr}`;
} else if (isTomorrow) {
return `Завтра, ${timeStr}`;
} else {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
} catch {
return '—';
}
};
export const UpcomingLessonsSection: React.FC<UpcomingLessonsSectionProps> = ({
data,
loading,
}) => {
const lessons = data?.upcoming_lessons?.slice(0, 4) || [];
const [flipped, setFlipped] = useState(false);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
const [details, setDetails] = useState<Lesson | null>(null);
const [loadingDetails, setLoadingDetails] = useState(false);
const selectedPreview = useMemo(
() => lessons.find((l) => l.id === selectedLessonId) || null,
[lessons, selectedLessonId],
);
const formatFullDateTime = (dateTimeStr: string | null): string => {
if (!dateTimeStr) return '—';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '—';
}
};
const openLessonDetails = async (lessonId: string) => {
setSelectedLessonId(lessonId);
setFlipped(true);
setLoadingDetails(true);
setDetails(null);
try {
const full = await getLesson(lessonId);
setDetails(full);
} catch (error) {
console.error('Ошибка загрузки занятия:', error);
} finally {
setLoadingDetails(false);
}
};
return (
<FlipCard
flipped={flipped}
onFlippedChange={(v) => {
// если пользователь кликнул по "пустому месту" в карточке — не хотим случайно закрывать,
// но оставим стандартное поведение: переворот по клику на сам контейнер FlipCard.
setFlipped(v);
}}
front={
<Panel padding="md">
<SectionHeader title="Ближайшие занятия" />
{loading && !data ? (
<LoadingSpinner size="medium" />
) : lessons.length === 0 ? (
<div
style={{
padding: 'var(--ios26-spacing-md) 0',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Нет запланированных занятий
</div>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: 'var(--ios26-spacing-sm)',
}}
onClick={(e) => e.stopPropagation()}
>
{lessons.map((lesson) => {
const client = lesson.client as any;
const fullName =
client?.first_name || client?.last_name
? `${client.first_name ?? ''} ${client.last_name ?? ''}`.trim()
: client?.name || 'Студент';
return (
<button
key={lesson.id}
type="button"
onClick={() => openLessonDetails(lesson.id)}
className="ios26-lesson-preview"
>
<div className="ios26-lesson-avatar">
{client?.avatar ? (
<img src={client.avatar} alt={fullName} />
) : (
<span>
{(fullName || 'С')[0]?.toUpperCase()}
</span>
)}
</div>
<div className="ios26-lesson-text">
<p className="ios26-lesson-subject">
{lesson.subject || 'Предмет не указан'}
</p>
<p className="ios26-lesson-student">
{fullName}
</p>
<p className="ios26-lesson-datetime">
{formatDateTime(lesson.start_time)}
</p>
</div>
</button>
);
})}
</div>
)}
</Panel>
}
back={
<Panel padding="md">
<SectionHeader
title="Детали занятия"
trailing={
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setFlipped(false);
}}
className="ios26-back-button"
>
Назад
</button>
}
/>
<div onClick={(e) => e.stopPropagation()}>
{loadingDetails ? (
<div
style={{
padding: 'var(--ios26-spacing-md) 0',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Загрузка...
</div>
) : details ? (
<div>
<p style={{ fontSize: 18, fontWeight: 700, margin: '0 0 8px 0' }}>
{details.title || selectedPreview?.title || 'Занятие'}
</p>
{details.subject && (
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
Предмет: {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>
<p style={{ fontSize: 14, margin: '0 0 4px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
<strong>Окончание:</strong> {formatFullDateTime(details.end_time)}
</p>
{details.description && (
<p style={{ fontSize: 14, margin: '10px 0 0 0', lineHeight: 1.5 }}>
{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)' }}>
<strong>Заметки:</strong> {details.mentor_notes}
</p>
)}
</div>
) : (
<div
style={{
padding: 'var(--ios26-spacing-md) 0',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Не удалось загрузить детали
</div>
)}
</div>
</Panel>
}
/>
);
};
/**
* Секция «Ближайшие занятия» для дашборда ментора (iOS 26).
*/
'use client';
import React, { useMemo, useState } from 'react';
import { MentorDashboardResponse } from '@/api/dashboard';
import { Panel, SectionHeader, FlipCard } from '../ui';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { getLesson, type Lesson } from '@/api/schedule';
export interface UpcomingLessonsSectionProps {
data: MentorDashboardResponse | null;
loading: boolean;
}
const formatDateTime = (dateTimeStr: string | null): string => {
if (!dateTimeStr) return '—';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '—';
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const isToday = date.toDateString() === today.toDateString();
const isTomorrow = date.toDateString() === tomorrow.toDateString();
const timeStr = date.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
});
if (isToday) {
return `Сегодня, ${timeStr}`;
} else if (isTomorrow) {
return `Завтра, ${timeStr}`;
} else {
return date.toLocaleDateString('ru-RU', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
} catch {
return '—';
}
};
export const UpcomingLessonsSection: React.FC<UpcomingLessonsSectionProps> = ({
data,
loading,
}) => {
const lessons = data?.upcoming_lessons?.slice(0, 4) || [];
const [flipped, setFlipped] = useState(false);
const [selectedLessonId, setSelectedLessonId] = useState<string | null>(null);
const [details, setDetails] = useState<Lesson | null>(null);
const [loadingDetails, setLoadingDetails] = useState(false);
const selectedPreview = useMemo(
() => lessons.find((l) => l.id === selectedLessonId) || null,
[lessons, selectedLessonId],
);
const formatFullDateTime = (dateTimeStr: string | null): string => {
if (!dateTimeStr) return '—';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '—';
return date.toLocaleString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return '—';
}
};
const openLessonDetails = async (lessonId: string) => {
setSelectedLessonId(lessonId);
setFlipped(true);
setLoadingDetails(true);
setDetails(null);
try {
const full = await getLesson(lessonId);
setDetails(full);
} catch (error) {
console.error('Ошибка загрузки занятия:', error);
} finally {
setLoadingDetails(false);
}
};
return (
<FlipCard
flipped={flipped}
onFlippedChange={(v) => {
// если пользователь кликнул по "пустому месту" в карточке — не хотим случайно закрывать,
// но оставим стандартное поведение: переворот по клику на сам контейнер FlipCard.
setFlipped(v);
}}
front={
<Panel padding="md">
<SectionHeader title="Ближайшие занятия" />
{loading && !data ? (
<LoadingSpinner size="medium" />
) : lessons.length === 0 ? (
<div
style={{
padding: 'var(--ios26-spacing-md) 0',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Нет запланированных занятий
</div>
) : (
<div
className="upcoming-lessons-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
gap: 'var(--ios26-spacing-sm)',
}}
onClick={(e) => e.stopPropagation()}
>
{lessons.map((lesson) => {
const client = lesson.client as any;
const fullName =
client?.first_name || client?.last_name
? `${client.first_name ?? ''} ${client.last_name ?? ''}`.trim()
: client?.name || 'Студент';
return (
<button
key={lesson.id}
type="button"
onClick={() => openLessonDetails(lesson.id)}
className="ios26-lesson-preview"
>
<div className="ios26-lesson-avatar">
{client?.avatar ? (
<img src={client.avatar} alt={fullName} />
) : (
<span>
{(fullName || 'С')[0]?.toUpperCase()}
</span>
)}
</div>
<div className="ios26-lesson-text">
<p className="ios26-lesson-subject">
{lesson.subject || 'Предмет не указан'}
</p>
<p className="ios26-lesson-student">
{fullName}
</p>
<p className="ios26-lesson-datetime">
{formatDateTime(lesson.start_time)}
</p>
</div>
</button>
);
})}
</div>
)}
</Panel>
}
back={
<Panel padding="md">
<SectionHeader
title="Детали занятия"
trailing={
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setFlipped(false);
}}
className="ios26-back-button"
>
Назад
</button>
}
/>
<div onClick={(e) => e.stopPropagation()}>
{loadingDetails ? (
<div
style={{
padding: 'var(--ios26-spacing-md) 0',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Загрузка...
</div>
) : details ? (
<div>
<p style={{ fontSize: 18, fontWeight: 700, margin: '0 0 8px 0' }}>
{details.title || selectedPreview?.title || 'Занятие'}
</p>
{details.subject && (
<p style={{ fontSize: 14, margin: '0 0 8px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
Предмет: {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>
<p style={{ fontSize: 14, margin: '0 0 4px 0', color: 'var(--md-sys-color-on-surface-variant)' }}>
<strong>Окончание:</strong> {formatFullDateTime(details.end_time)}
</p>
{details.description && (
<p style={{ fontSize: 14, margin: '10px 0 0 0', lineHeight: 1.5 }}>
{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)' }}>
<strong>Заметки:</strong> {details.mentor_notes}
</p>
)}
</div>
) : (
<div
style={{
padding: 'var(--ios26-spacing-md) 0',
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Не удалось загрузить детали
</div>
)}
</div>
</Panel>
}
/>
);
};

View File

@ -1,47 +1,46 @@
/**
* Сетка карточек статистики. Переиспользуется для всех ролей.
*/
'use client';
import React from 'react';
import { StatCard } from '../StatCard';
export interface StatsGridItem {
title: string;
value: string | number;
icon?: React.ReactNode;
trend?: { value: number; isPositive: boolean };
subtitle?: string;
loading?: boolean;
}
export interface StatsGridProps {
items: StatsGridItem[];
}
export const StatsGrid: React.FC<StatsGridProps> = ({ items }) => {
return (
<div
style={{
display: 'grid',
// Для дашборда ментора: 4 карточки в один ряд,
// чтобы верхняя строка занимала всю ширину.
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 'var(--ios26-spacing)',
}}
>
{items.map((item, i) => (
<StatCard
key={i}
title={item.title}
value={item.value}
icon={item.icon}
trend={item.trend}
subtitle={item.subtitle}
loading={item.loading}
/>
))}
</div>
);
};
/**
* Сетка карточек статистики. Переиспользуется для всех ролей.
*/
'use client';
import React from 'react';
import { StatCard } from '../StatCard';
export interface StatsGridItem {
title: string;
value: string | number;
icon?: React.ReactNode;
trend?: { value: number; isPositive: boolean };
subtitle?: string;
loading?: boolean;
}
export interface StatsGridProps {
items: StatsGridItem[];
}
export const StatsGrid: React.FC<StatsGridProps> = ({ items }) => {
return (
<div
className="dashboard-stats-grid"
style={{
display: 'grid',
gridTemplateColumns: 'repeat(4, minmax(0, 1fr))',
gap: 'var(--ios26-spacing)',
}}
>
{items.map((item, i) => (
<StatCard
key={i}
title={item.title}
value={item.value}
icon={item.icon}
trend={item.trend}
subtitle={item.subtitle}
loading={item.loading}
/>
))}
</div>
);
};

View File

@ -1,305 +1,382 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { NavBadges } from '@/api/navBadges';
import { ChildSelectorCompact } from '@/components/navigation/ChildSelector';
interface NavigationItem {
label: string;
path: string;
icon: string;
isProfile?: boolean;
}
interface User {
id?: number;
first_name?: string;
last_name?: string;
email?: string;
avatar_url?: string | null;
avatar?: string | null;
}
interface BottomNavigationBarProps {
userRole?: string;
user?: User | null;
navBadges?: NavBadges | null;
/** Слот для кнопки уведомлений (на мобильном — 4-й элемент в первом ряду). */
notificationsSlot?: React.ReactNode;
/** Выдвижная панель справа (3 колонки). При клике по пункту вызывается onClose. */
slideout?: boolean;
onClose?: () => void;
}
function getAvatarUrl(user: User | null | undefined): string | null {
if (!user) return null;
const url = user.avatar_url || user.avatar;
if (!url) return null;
if (url.startsWith('http')) return 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;
switch (item.path) {
case '/schedule':
return navBadges.lessons_today;
case '/chat':
return navBadges.chat_unread;
case '/homework':
return navBadges.homework_pending;
case '/feedback':
return navBadges.feedback_pending;
case '/students':
return navBadges.mentorship_requests_pending ?? 0;
default:
return 0;
}
}
export function BottomNavigationBar({ userRole, user, navBadges, notificationsSlot, slideout, onClose }: BottomNavigationBarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const tabParam = searchParams?.get('tab');
const [activeIndex, setActiveIndex] = useState(0);
const [expanded, setExpanded] = useState(false);
const avatarUrl = getAvatarUrl(user);
// Определяем навигационные элементы в зависимости от роли
const navigationItems = useMemo<NavigationItem[]>(() => {
const baseItems: NavigationItem[] = [
{ label: 'Главная', path: '/dashboard', icon: 'home' },
{ label: 'Расписание', path: '/schedule', icon: 'calendar_month' },
{ label: 'Чат', path: '/chat', icon: 'chat' },
];
let roleItems: NavigationItem[] = [];
if (userRole === 'mentor') {
roleItems = [
{ label: 'Студенты', path: '/students', icon: 'group' },
{ label: 'Материалы', path: '/materials', icon: 'folder' },
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
{ label: 'Обратная связь', path: '/feedback', icon: 'rate_review' },
];
} else if (userRole === 'client') {
roleItems = [
{ label: 'Материалы', path: '/materials', icon: 'folder' },
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
{ label: 'Мои менторы', path: '/request-mentor', icon: 'person_add' },
];
} else if (userRole === 'parent') {
// Родитель: те же страницы, что и студент, кроме материалов
roleItems = [
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
];
}
const common: NavigationItem[] = [
...baseItems,
...roleItems,
{ label: 'Профиль', path: '/profile', icon: 'person', isProfile: true },
];
// Аналитика, Тарифы и Рефералы только для ментора
if (userRole === 'mentor') {
common.push(
{ label: 'Аналитика', path: '/analytics', icon: 'analytics' },
{ label: 'Тарифы', path: '/payment', icon: 'credit_card' },
{ label: 'Рефералы', path: '/referrals', icon: 'group_add' }
);
}
return common;
}, [userRole]);
const firstRowItems = navigationItems.slice(0, notificationsSlot ? 3 : 5);
const restItems = navigationItems.slice(notificationsSlot ? 3 : 5);
const hasMore = restItems.length > 0;
// Подсветка активного таба по текущему URL
useEffect(() => {
const idx = navigationItems.findIndex((item) => {
if (item.path === '/payment') return pathname === '/payment';
if (item.path === '/analytics') return pathname === '/analytics';
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;
if (item.path === '/request-mentor') return pathname === '/request-mentor';
return pathname?.startsWith(item.path);
});
if (idx !== -1) setActiveIndex(idx);
}, [pathname, navigationItems, tabParam]);
const handleTabClick = (index: number) => {
const item = navigationItems[index];
if (!item) return;
setActiveIndex(index);
setExpanded(false);
router.push(item.path);
onClose?.();
};
if (!navigationItems.length) return null;
const renderButton = (item: NavigationItem, index: number) => {
const isActive = index === activeIndex;
const showAvatar = item.isProfile && (avatarUrl || user);
const badgeCount = getBadgeCount(item, navBadges);
return (
<button
key={item.path}
type="button"
onClick={() => handleTabClick(index)}
className={
'ios26-bottom-nav-button' +
(isActive ? ' ios26-bottom-nav-button--active' : '')
}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
{showAvatar ? (
<span
className="ios26-bottom-nav-icon"
style={{
width: 24,
height: 24,
borderRadius: '50%',
overflow: 'hidden',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-primary)',
fontSize: 12,
fontWeight: 600,
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
user && (user.first_name?.charAt(0) || user.email?.charAt(0) || 'У')
)}
</span>
) : (
<span className="material-symbols-outlined ios26-bottom-nav-icon">
{item.icon}
</span>
)}
{badgeCount > 0 && (
<span
className="ios26-bottom-nav-badge"
style={{
position: 'absolute',
top: -8,
right: -16,
minWidth: 18,
height: 18,
borderRadius: 9,
background: 'var(--md-sys-color-error, #b3261e)',
color: '#fff',
fontSize: 11,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
boxSizing: 'border-box',
}}
>
{badgeCount > 99 ? '99+' : badgeCount}
</span>
)}
</span>
<span className="ios26-bottom-nav-label">
{item.label}
</span>
</button>
);
};
if (slideout) {
return (
<div className="ios26-bottom-nav-slideout">
<div className="ios26-bottom-nav ios26-bottom-nav-slideout-inner">
{userRole === 'parent' && (
<div style={{ gridColumn: '1 / -1', marginBottom: 8 }}>
<ChildSelectorCompact />
</div>
)}
{navigationItems.map((item, i) => renderButton(item, i))}
</div>
</div>
);
}
return (
<div
className={
'ios26-bottom-nav-container' +
(expanded ? ' ios26-bottom-nav-container--expanded' : '')
}
>
{hasMore && (
<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' : '') +
(notificationsSlot ? ' ios26-bottom-nav-first-row--with-notifications' : '')
}
>
{userRole === 'parent' && <ChildSelectorCompact />}
{userRole === 'parent' ? (
<div
className={
'ios26-bottom-nav-first-row-buttons' +
(notificationsSlot ? ' ios26-bottom-nav-first-row-buttons--with-notifications' : '')
}
>
{firstRowItems.map((item, i) => renderButton(item, i))}
{notificationsSlot}
</div>
) : (
<>
{firstRowItems.map((item, i) => renderButton(item, i))}
{notificationsSlot}
</>
)}
</div>
<div
className={'ios26-bottom-nav-rest' + (expanded ? ' ios26-bottom-nav-rest--expanded' : '')}
>
{restItems.map((item, i) => renderButton(item, (notificationsSlot ? 3 : 5) + i))}
</div>
</div>
</div>
);
}
'use client';
import { useEffect, useMemo, useState, useRef, useCallback } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import type { NavBadges } from '@/api/navBadges';
import { ChildSelectorCompact } from '@/components/navigation/ChildSelector';
import { useIsMobile } from '@/hooks/useIsMobile';
interface NavigationItem {
label: string;
path: string;
icon: string;
isProfile?: boolean;
}
interface User {
id?: number;
first_name?: string;
last_name?: string;
email?: string;
avatar_url?: string | null;
avatar?: string | null;
}
interface BottomNavigationBarProps {
userRole?: string;
user?: User | null;
navBadges?: NavBadges | null;
/** Слот для кнопки уведомлений (на мобильном — 4-й элемент в первом ряду). */
notificationsSlot?: React.ReactNode;
/** Выдвижная панель справа (3 колонки). При клике по пункту вызывается onClose. */
slideout?: boolean;
onClose?: () => void;
}
function getAvatarUrl(user: User | null | undefined): string | null {
if (!user) return null;
const url = user.avatar_url || user.avatar;
if (!url) return null;
if (url.startsWith('http')) return 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;
switch (item.path) {
case '/schedule':
return navBadges.lessons_today;
case '/chat':
return navBadges.chat_unread;
case '/homework':
return navBadges.homework_pending;
case '/feedback':
return navBadges.feedback_pending;
case '/students':
return navBadges.mentorship_requests_pending ?? 0;
default:
return 0;
}
}
export function BottomNavigationBar({ userRole, user, navBadges, notificationsSlot, slideout, onClose }: BottomNavigationBarProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const tabParam = searchParams?.get('tab');
const [activeIndex, setActiveIndex] = useState(0);
const [expanded, setExpanded] = useState(false);
const avatarUrl = getAvatarUrl(user);
const isMobile = useIsMobile();
// Swipe gesture handling (secondary to "More" button)
const navContainerRef = useRef<HTMLDivElement>(null);
const touchStartY = useRef<number | null>(null);
const touchStartX = useRef<number | null>(null);
const SWIPE_THRESHOLD = 30;
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartY.current = e.touches[0].clientY;
touchStartX.current = e.touches[0].clientX;
}, []);
const handleTouchEnd = useCallback((e: React.TouchEvent) => {
if (touchStartY.current === null || touchStartX.current === null) return;
const deltaY = touchStartY.current - e.changedTouches[0].clientY;
const deltaX = Math.abs(touchStartX.current - e.changedTouches[0].clientX);
touchStartY.current = null;
touchStartX.current = null;
if (Math.abs(deltaY) < SWIPE_THRESHOLD || deltaX > Math.abs(deltaY)) return;
if (deltaY > 0) {
setExpanded(true);
} else {
setExpanded(false);
}
}, []);
// Определяем навигационные элементы в зависимости от роли
const navigationItems = useMemo<NavigationItem[]>(() => {
const baseItems: NavigationItem[] = [
{ label: 'Главная', path: '/dashboard', icon: 'home' },
{ label: 'Расписание', path: '/schedule', icon: 'calendar_month' },
{ label: 'Чат', path: '/chat', icon: 'chat' },
];
let roleItems: NavigationItem[] = [];
if (userRole === 'mentor') {
roleItems = [
{ label: 'Студенты', path: '/students', icon: 'group' },
{ label: 'Материалы', path: '/materials', icon: 'folder' },
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
{ label: 'Обратная связь', path: '/feedback', icon: 'rate_review' },
];
} else if (userRole === 'client') {
roleItems = [
{ label: 'Материалы', path: '/materials', icon: 'folder' },
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
{ label: 'Мои менторы', path: '/request-mentor', icon: 'person_add' },
];
} else if (userRole === 'parent') {
// Родитель: те же страницы, что и студент, кроме материалов
roleItems = [
{ label: 'Домашние задания', path: '/homework', icon: 'assignment' },
{ label: 'Прогресс', path: '/my-progress', icon: 'trending_up' },
];
}
const common: NavigationItem[] = [
...baseItems,
...roleItems,
{ label: 'Профиль', path: '/profile', icon: 'person', isProfile: true },
];
// Аналитика, Тарифы и Рефералы только для ментора
if (userRole === 'mentor') {
common.push(
{ label: 'Аналитика', path: '/analytics', icon: 'analytics' },
{ label: 'Тарифы', path: '/payment', icon: 'credit_card' },
{ label: 'Рефералы', path: '/referrals', icon: 'group_add' }
);
}
return common;
}, [userRole]);
// Mobile: first 3 items + "More" button; Desktop: first 3/5 items + notifications
const MOBILE_FIRST_ROW_COUNT = 3;
const desktopFirstCount = notificationsSlot ? 3 : 5;
const firstRowItems = isMobile
? navigationItems.slice(0, MOBILE_FIRST_ROW_COUNT)
: navigationItems.slice(0, desktopFirstCount);
const restItems = isMobile
? navigationItems.slice(MOBILE_FIRST_ROW_COUNT)
: navigationItems.slice(desktopFirstCount);
const hasMore = restItems.length > 0;
// Подсветка активного таба по текущему URL
useEffect(() => {
const idx = navigationItems.findIndex((item) => {
if (item.path === '/payment') return pathname === '/payment';
if (item.path === '/analytics') return pathname === '/analytics';
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;
if (item.path === '/request-mentor') return pathname === '/request-mentor';
return pathname?.startsWith(item.path);
});
if (idx !== -1) setActiveIndex(idx);
}, [pathname, navigationItems, tabParam]);
const handleTabClick = (index: number) => {
const item = navigationItems[index];
if (!item) return;
setActiveIndex(index);
setExpanded(false);
router.push(item.path);
onClose?.();
};
if (!navigationItems.length) return null;
const renderButton = (item: NavigationItem, index: number) => {
const isActive = index === activeIndex;
const showAvatar = item.isProfile && (avatarUrl || user);
const badgeCount = getBadgeCount(item, navBadges);
return (
<button
key={item.path}
type="button"
onClick={() => handleTabClick(index)}
className={
'ios26-bottom-nav-button' +
(isActive ? ' ios26-bottom-nav-button--active' : '')
}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
{showAvatar ? (
<span
className="ios26-bottom-nav-icon"
style={{
width: 24,
height: 24,
borderRadius: '50%',
overflow: 'hidden',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-primary)',
fontSize: 12,
fontWeight: 600,
}}
>
{avatarUrl ? (
<img
src={avatarUrl}
alt=""
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : (
user && (user.first_name?.charAt(0) || user.email?.charAt(0) || 'У')
)}
</span>
) : (
<span className="material-symbols-outlined ios26-bottom-nav-icon">
{item.icon}
</span>
)}
{badgeCount > 0 && (
<span
className="ios26-bottom-nav-badge"
style={{
position: 'absolute',
top: -8,
right: -16,
minWidth: 18,
height: 18,
borderRadius: 9,
background: 'var(--md-sys-color-error, #b3261e)',
color: '#fff',
fontSize: 11,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
boxSizing: 'border-box',
}}
>
{badgeCount > 99 ? '99+' : badgeCount}
</span>
)}
</span>
<span className="ios26-bottom-nav-label">
{item.label}
</span>
</button>
);
};
if (slideout) {
return (
<div className="ios26-bottom-nav-slideout">
<div className="ios26-bottom-nav ios26-bottom-nav-slideout-inner">
{userRole === 'parent' && (
<div style={{ gridColumn: '1 / -1', marginBottom: 8 }}>
<ChildSelectorCompact />
</div>
)}
{navigationItems.map((item, i) => renderButton(item, i))}
</div>
</div>
);
}
// "More" button for mobile
const renderMoreButton = () => (
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className={
'ios26-bottom-nav-button' +
(expanded ? ' ios26-bottom-nav-button--active' : '')
}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
<span className="material-symbols-outlined ios26-bottom-nav-icon">
{expanded ? 'close' : 'more_horiz'}
</span>
</span>
<span className="ios26-bottom-nav-label">
{expanded ? 'Закрыть' : 'Ещё'}
</span>
</button>
);
// 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>
);
}

View File

@ -1,403 +1,424 @@
'use client';
import { useState, useRef, useEffect } from 'react';
import { useNotifications } from '@/hooks/useNotifications';
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
import type { Notification } from '@/api/notifications';
const BELL_POSITION = { right: 24, bottom: 25 };
const PANEL_WIDTH = 360;
const PANEL_MAX_HEIGHT = '70vh';
function formatDate(s: string) {
try {
const d = new Date(s);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
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' });
} catch {
return '';
}
}
function NotificationItem({
notification,
onHover,
onClick,
}: {
notification: Notification;
onHover: () => void;
onClick: () => void;
}) {
const hoveredRef = useRef(false);
const handleMouseEnter = () => {
hoveredRef.current = true;
onHover();
};
return (
<div
role="button"
tabIndex={0}
onMouseEnter={handleMouseEnter}
onClick={onClick}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--md-sys-color-outline-variant, #eee)',
cursor: notification.action_url ? 'pointer' : 'default',
backgroundColor: notification.is_read
? 'transparent'
: '#bfa9ff',
transition: 'background-color 0.15s ease',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: 14,
color: 'var(--md-sys-color-on-surface)',
marginBottom: 4,
}}
>
{notification.title || 'Уведомление'}
</div>
<div
style={{
fontSize: 13,
color: 'var(--md-sys-color-on-surface-variant)',
lineHeight: 1.4,
}}
>
{notification.message}
</div>
</div>
<span
style={{
fontSize: 11,
color: 'var(--md-sys-color-on-surface-variant)',
whiteSpace: 'nowrap',
}}
>
{formatDate(notification.created_at)}
</span>
</div>
</div>
);
}
const SCROLL_LOAD_MORE_THRESHOLD = 80;
export function NotificationBell({ embedded }: { embedded?: boolean }) {
const refreshNavBadges = useNavBadgesRefresh();
const {
list,
unreadCount,
loading,
loadingMore,
hasMore,
fetchList,
fetchMore,
markOneAsRead,
markAllAsRead,
} = useNotifications({ onNavBadgesUpdate: refreshNavBadges ?? undefined });
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) fetchList();
}, [open, fetchList]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el || !hasMore || loadingMore) return;
const { scrollTop, scrollHeight, clientHeight } = el;
if (scrollTop + clientHeight >= scrollHeight - SCROLL_LOAD_MORE_THRESHOLD) {
fetchMore();
}
};
// Порядок не сортируем: API уже отдаёт is_read, -created_at. Иначе при подгрузке непрочитанные из новой страницы уезжали бы наверх, а скролл остаётся внизу.
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
!(e.target as HTMLElement).closest('[data-notification-bell]')
) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
return (
<>
<style>{`
.notification-panel-enter {
transform: translateX(calc(100% + 16px));
opacity: 0;
}
.notification-panel-enter-active {
transform: translateX(0);
opacity: 1;
transition: transform 0.25s ease-out, opacity 0.2s ease-out;
}
.notification-panel-exit {
transform: translateX(0);
opacity: 1;
}
.notification-panel-exit-active {
transform: translateX(calc(100% + 16px));
opacity: 0;
transition: transform 0.2s ease-in, opacity 0.15s ease-in;
}
`}</style>
<div
data-notification-bell
style={
embedded
? {
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
}
: {
position: 'fixed',
right: BELL_POSITION.right,
bottom: BELL_POSITION.bottom,
zIndex: 9998,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'flex-end',
flexDirection: 'column',
}
}
>
{/* Панель уведомлений — выезжает справа от колокольчика */}
{open && (
<div
ref={panelRef}
className="notification-panel-enter-active"
style={{
position: 'absolute',
...(embedded
? { bottom: '100%', marginBottom: 8, left: '50%', transform: 'translateX(-50%)' }
: { right: 52, bottom: 0 }),
width: PANEL_WIDTH,
maxHeight: PANEL_MAX_HEIGHT,
backgroundColor: 'var(--md-sys-color-surface)',
borderRadius: 'var(--ios26-radius-md, 24px)',
boxShadow: 'var(--ios-shadow-deep, 0 18px 50px rgba(0,0,0,0.12))',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: 9999,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '16px 16px 12px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<span
style={{
fontWeight: 600,
fontSize: 18,
color: 'var(--md-sys-color-on-surface)',
}}
>
Уведомления
</span>
{unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
style={{
padding: '6px 12px',
fontSize: 13,
fontWeight: 500,
color: 'var(--md-sys-color-primary)',
background: 'transparent',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
}}
>
Прочитать все
</button>
)}
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
style={{
overflowY: 'auto',
overflowX: 'hidden',
flex: 1,
minHeight: 120,
}}
>
{loading ? (
<div
style={{
padding: 24,
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Загрузка
</div>
) : list.length === 0 ? (
<div
style={{
padding: 24,
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Нет уведомлений
</div>
) : (
<>
{list.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onHover={() => !n.is_read && markOneAsRead(n.id)}
onClick={() => {
/* Не переходим по action_url — только открыта панель уведомлений */
}}
/>
))}
{loadingMore && (
<div
style={{
padding: 12,
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 13,
}}
>
Загрузка
</div>
)}
</>
)}
</div>
</div>
)}
{/* Кнопка-колокольчик: в меню — как пункт навигации, иначе — круглая */}
{embedded ? (
<button
type="button"
className="ios26-bottom-nav-button"
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
onClick={() => setOpen((o) => !o)}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
<span className="material-symbols-outlined ios26-bottom-nav-icon">
notifications
</span>
{unreadCount > 0 && (
<span
className="ios26-bottom-nav-badge"
style={{
position: 'absolute',
top: -8,
right: -16,
minWidth: 18,
height: 18,
borderRadius: 9,
background: 'var(--md-sys-color-error, #b3261e)',
color: '#fff',
fontSize: 11,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
boxSizing: 'border-box',
}}
title={`${unreadCount} непрочитанных`}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</span>
<span className="ios26-bottom-nav-label">Уведомления</span>
</button>
) : (
<button
type="button"
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
onClick={() => setOpen((o) => !o)}
style={{
position: 'relative',
width: 48,
height: 48,
borderRadius: '50%',
border: 'none',
background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-primary)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--ios-shadow-soft)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
notifications
</span>
{unreadCount > 0 && (
<span
style={{
position: 'absolute',
top: -2,
right: -2,
minWidth: 18,
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>
</>
);
}
'use client';
import { useState, useRef, useEffect } from 'react';
import { useNotifications } from '@/hooks/useNotifications';
import { useNavBadgesRefresh } from '@/contexts/NavBadgesContext';
import type { Notification } from '@/api/notifications';
const BELL_POSITION = { right: 24, bottom: 25 };
const PANEL_WIDTH = 360;
const PANEL_MAX_HEIGHT = '70vh';
function formatDate(s: string) {
try {
const d = new Date(s);
const now = new Date();
const sameDay = d.toDateString() === now.toDateString();
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' });
} catch {
return '';
}
}
function NotificationItem({
notification,
onHover,
onClick,
}: {
notification: Notification;
onHover: () => void;
onClick: () => void;
}) {
const hoveredRef = useRef(false);
const handleMouseEnter = () => {
hoveredRef.current = true;
onHover();
};
return (
<div
role="button"
tabIndex={0}
onMouseEnter={handleMouseEnter}
onClick={onClick}
onKeyDown={(e) => e.key === 'Enter' && onClick()}
style={{
padding: '12px 16px',
borderBottom: '1px solid var(--md-sys-color-outline-variant, #eee)',
cursor: notification.action_url ? 'pointer' : 'default',
backgroundColor: notification.is_read
? 'transparent'
: '#bfa9ff',
transition: 'background-color 0.15s ease',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 8 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontWeight: 600,
fontSize: 14,
color: 'var(--md-sys-color-on-surface)',
marginBottom: 4,
}}
>
{notification.title || 'Уведомление'}
</div>
<div
style={{
fontSize: 13,
color: 'var(--md-sys-color-on-surface-variant)',
lineHeight: 1.4,
}}
>
{notification.message}
</div>
</div>
<span
style={{
fontSize: 11,
color: 'var(--md-sys-color-on-surface-variant)',
whiteSpace: 'nowrap',
}}
>
{formatDate(notification.created_at)}
</span>
</div>
</div>
);
}
const SCROLL_LOAD_MORE_THRESHOLD = 80;
export function NotificationBell({ embedded }: { embedded?: boolean }) {
const refreshNavBadges = useNavBadgesRefresh();
const {
list,
unreadCount,
loading,
loadingMore,
hasMore,
fetchList,
fetchMore,
markOneAsRead,
markAllAsRead,
} = useNotifications({ onNavBadgesUpdate: refreshNavBadges ?? undefined });
const [open, setOpen] = useState(false);
const panelRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (open) fetchList();
}, [open, fetchList]);
const handleScroll = () => {
const el = scrollRef.current;
if (!el || !hasMore || loadingMore) return;
const { scrollTop, scrollHeight, clientHeight } = el;
if (scrollTop + clientHeight >= scrollHeight - SCROLL_LOAD_MORE_THRESHOLD) {
fetchMore();
}
};
// Порядок не сортируем: API уже отдаёт is_read, -created_at. Иначе при подгрузке непрочитанные из новой страницы уезжали бы наверх, а скролл остаётся внизу.
useEffect(() => {
if (!open) return;
const handleClickOutside = (e: MouseEvent) => {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
!(e.target as HTMLElement).closest('[data-notification-bell]')
) {
setOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open]);
return (
<>
<style>{`
.notification-panel-enter {
transform: translateX(calc(100% + 16px));
opacity: 0;
}
.notification-panel-enter-active {
transform: translateX(0);
opacity: 1;
transition: transform 0.25s ease-out, opacity 0.2s ease-out;
}
.notification-panel-exit {
transform: translateX(0);
opacity: 1;
}
.notification-panel-exit-active {
transform: translateX(calc(100% + 16px));
opacity: 0;
transition: transform 0.2s ease-in, opacity 0.15s ease-in;
}
`}</style>
<div
data-notification-bell
style={
embedded
? {
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'column',
}
: {
position: 'fixed',
right: BELL_POSITION.right,
bottom: BELL_POSITION.bottom,
zIndex: 9998,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'flex-end',
flexDirection: 'column',
}
}
>
{/* Панель уведомлений — выезжает справа от колокольчика */}
{open && (
<div
ref={panelRef}
className="notification-panel-enter-active notification-panel"
style={{
position: embedded ? 'fixed' : 'absolute',
...(embedded
? { bottom: 'auto', left: 8, right: 8, top: 'auto', transform: 'none' }
: { right: 52, bottom: 0 }),
width: embedded ? 'auto' : `min(${PANEL_WIDTH}px, calc(100vw - 32px))`,
maxHeight: PANEL_MAX_HEIGHT,
backgroundColor: 'var(--md-sys-color-surface)',
borderRadius: 'var(--ios26-radius-md, 24px)',
boxShadow: 'var(--ios-shadow-deep, 0 18px 50px rgba(0,0,0,0.12))',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
zIndex: 9999,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 12,
padding: '16px 16px 12px',
borderBottom: '1px solid var(--md-sys-color-outline-variant)',
}}
>
<span
style={{
fontWeight: 600,
fontSize: 18,
color: 'var(--md-sys-color-on-surface)',
}}
>
Уведомления
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{unreadCount > 0 && (
<button
type="button"
onClick={markAllAsRead}
style={{
padding: '6px 12px',
fontSize: 13,
fontWeight: 500,
color: 'var(--md-sys-color-primary)',
background: 'transparent',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
}}
>
Прочитать все
</button>
)}
<button
type="button"
onClick={() => setOpen(false)}
aria-label="Закрыть"
style={{
all: 'unset',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: 32,
height: 32,
borderRadius: '50%',
color: 'var(--md-sys-color-on-surface-variant)',
flexShrink: 0,
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>close</span>
</button>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
style={{
overflowY: 'auto',
overflowX: 'hidden',
flex: 1,
minHeight: 120,
}}
>
{loading ? (
<div
style={{
padding: 24,
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Загрузка
</div>
) : list.length === 0 ? (
<div
style={{
padding: 24,
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 14,
}}
>
Нет уведомлений
</div>
) : (
<>
{list.map((n) => (
<NotificationItem
key={n.id}
notification={n}
onHover={() => !n.is_read && markOneAsRead(n.id)}
onClick={() => {
/* Не переходим по action_url — только открыта панель уведомлений */
}}
/>
))}
{loadingMore && (
<div
style={{
padding: 12,
textAlign: 'center',
color: 'var(--md-sys-color-on-surface-variant)',
fontSize: 13,
}}
>
Загрузка
</div>
)}
</>
)}
</div>
</div>
)}
{/* Кнопка-колокольчик: в меню — как пункт навигации, иначе — круглая */}
{embedded ? (
<button
type="button"
className="ios26-bottom-nav-button"
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
onClick={() => setOpen((o) => !o)}
>
<span style={{ position: 'relative', display: 'inline-flex' }}>
<span className="material-symbols-outlined ios26-bottom-nav-icon">
notifications
</span>
{unreadCount > 0 && (
<span
className="ios26-bottom-nav-badge"
style={{
position: 'absolute',
top: -8,
right: -16,
minWidth: 18,
height: 18,
borderRadius: 9,
background: 'var(--md-sys-color-error, #b3261e)',
color: '#fff',
fontSize: 11,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
boxSizing: 'border-box',
}}
title={`${unreadCount} непрочитанных`}
>
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</span>
<span className="ios26-bottom-nav-label">Уведомления</span>
</button>
) : (
<button
type="button"
aria-label={unreadCount > 0 ? `Уведомления: ${unreadCount} непрочитанных` : 'Уведомления'}
onClick={() => setOpen((o) => !o)}
style={{
position: 'relative',
width: 48,
height: 48,
borderRadius: '50%',
border: 'none',
background: 'var(--md-sys-color-primary-container)',
color: 'var(--md-sys-color-primary)',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: 'var(--ios-shadow-soft)',
}}
>
<span className="material-symbols-outlined" style={{ fontSize: 24 }}>
notifications
</span>
{unreadCount > 0 && (
<span
style={{
position: 'absolute',
top: -2,
right: -2,
minWidth: 18,
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>
</>
);
}

View File

@ -1,205 +1,207 @@
'use client';
import { useState, useEffect } from 'react';
import { getReferralProfile, getReferralStats, getMyReferrals, type MyReferralItem } from '@/api/referrals';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { useToast } from '@/contexts/ToastContext';
const formatCurrency = (v: number) =>
new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v);
function formatDate(s: string) {
try {
return new Date(s).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return s;
}
}
export function ReferralsPageContent() {
const { showToast } = useToast();
const [profile, setProfile] = useState<any>(null);
const [stats, setStats] = useState<any>(null);
const [referralsList, setReferralsList] = useState<{ direct: MyReferralItem[]; indirect: MyReferralItem[] } | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
Promise.all([
getReferralProfile().then(setProfile),
getReferralStats().then(setStats),
getMyReferrals().then(setReferralsList).catch(() => setReferralsList({ direct: [], indirect: [] })),
])
.finally(() => setLoading(false));
}, []);
const copyLink = () => {
if (profile?.referral_link) {
navigator.clipboard.writeText(profile.referral_link);
setCopied(true);
showToast('Реферальная ссылка скопирована', 'success');
setTimeout(() => setCopied(false), 2000);
}
};
if (loading) {
return <LoadingSpinner size="medium" />;
}
if (!profile) {
return (
<p style={{ color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
Реферальная программа недоступна
</p>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.05em',
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 8,
}}
>
РЕФЕРАЛЬНАЯ ССЫЛКА
</div>
<div style={{ display: 'flex', gap: 8 }}>
<input
type="text"
readOnly
value={profile.referral_link || ''}
style={{
flex: 1,
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-container-low)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
}}
/>
<button
type="button"
onClick={copyLink}
style={{
padding: '12px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
}}
>
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
{profile.referral_code && (
<p style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 8 }}>
Код: <strong>{profile.referral_code}</strong>
</p>
)}
</div>
{stats && (
<div
style={{
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>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
{stats.current_level?.name || '-'}
</div>
</div>
<div className="ios26-panel" style={{ padding: 16 }}>
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
Баллы
</div>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
{stats.total_points ?? 0}
</div>
</div>
<div className="ios26-panel" style={{ padding: 16 }}>
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
Рефералов
</div>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
{stats.referrals?.total ?? 0}
</div>
</div>
<div className="ios26-panel" style={{ padding: 16 }}>
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
Заработано
</div>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
{formatCurrency(stats.earnings?.total ?? stats.bonus_account?.balance ?? 0)}
</div>
</div>
</div>
)}
{/* Список приглашённых рефералов */}
{referralsList && (referralsList.direct.length > 0 || referralsList.indirect.length > 0) && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
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 style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
Прямые рефералы ({referralsList.direct.length})
</div>
<ul style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
{referralsList.direct.map((r: MyReferralItem, i: number) => (
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}>
{r.email} {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
</li>
))}
</ul>
</div>
)}
{referralsList.indirect.length > 0 && (
<div>
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
Рефералы ваших рефералов ({referralsList.indirect.length})
</div>
<ul style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
{referralsList.indirect.map((r: MyReferralItem, i: number) => (
<li key={i} style={{ marginBottom: 4, fontSize: 14, color: 'var(--md-sys-color-on-surface)' }}>
{r.email} {r.level}, {r.total_points} баллов, зарегистрирован {formatDate(r.created_at)}
</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{referralsList && referralsList.direct.length === 0 && referralsList.indirect.length === 0 && (
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
Пока никого нет. Поделитесь реферальной ссылкой когда кто-то зарегистрируется по ней, он появится здесь и вы получите уведомление.
</p>
)}
</div>
);
}
'use client';
import { useState, useEffect } from 'react';
import { getReferralProfile, getReferralStats, getMyReferrals, type MyReferralItem } from '@/api/referrals';
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
import { useToast } from '@/contexts/ToastContext';
const formatCurrency = (v: number) =>
new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'RUB', maximumFractionDigits: 0 }).format(v);
function formatDate(s: string) {
try {
return new Date(s).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return s;
}
}
export function ReferralsPageContent() {
const { showToast } = useToast();
const [profile, setProfile] = useState<any>(null);
const [stats, setStats] = useState<any>(null);
const [referralsList, setReferralsList] = useState<{ direct: MyReferralItem[]; indirect: MyReferralItem[] } | null>(null);
const [loading, setLoading] = useState(true);
const [copied, setCopied] = useState(false);
useEffect(() => {
Promise.all([
getReferralProfile().then(setProfile),
getReferralStats().then(setStats),
getMyReferrals().then(setReferralsList).catch(() => setReferralsList({ direct: [], indirect: [] })),
])
.finally(() => setLoading(false));
}, []);
const copyLink = () => {
if (profile?.referral_link) {
navigator.clipboard.writeText(profile.referral_link);
setCopied(true);
showToast('Реферальная ссылка скопирована', 'success');
setTimeout(() => setCopied(false), 2000);
}
};
if (loading) {
return <LoadingSpinner size="medium" />;
}
if (!profile) {
return (
<p style={{ color: 'var(--md-sys-color-on-surface-variant)', fontSize: 14 }}>
Реферальная программа недоступна
</p>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
letterSpacing: '0.05em',
color: 'var(--md-sys-color-on-surface-variant)',
marginBottom: 8,
}}
>
РЕФЕРАЛЬНАЯ ССЫЛКА
</div>
<div className="referral-link-row" style={{ display: 'flex', gap: 8 }}>
<input
type="text"
readOnly
value={profile.referral_link || ''}
style={{
flex: 1,
minWidth: 0,
padding: '12px 16px',
borderRadius: 12,
border: '1px solid var(--md-sys-color-outline)',
background: 'var(--md-sys-color-surface-container-low)',
color: 'var(--md-sys-color-on-surface)',
fontSize: 14,
}}
/>
<button
type="button"
onClick={copyLink}
style={{
padding: '12px 20px',
borderRadius: 12,
border: 'none',
background: 'var(--md-sys-color-primary)',
color: 'var(--md-sys-color-on-primary)',
fontSize: 14,
fontWeight: 500,
cursor: 'pointer',
}}
>
{copied ? 'Скопировано' : 'Копировать'}
</button>
</div>
{profile.referral_code && (
<p style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginTop: 8 }}>
Код: <strong>{profile.referral_code}</strong>
</p>
)}
</div>
{stats && (
<div
className="referral-stats-grid"
style={{
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>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
{stats.current_level?.name || '-'}
</div>
</div>
<div className="ios26-panel" style={{ padding: 16 }}>
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
Баллы
</div>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
{stats.total_points ?? 0}
</div>
</div>
<div className="ios26-panel" style={{ padding: 16 }}>
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
Рефералов
</div>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-on-surface)' }}>
{stats.referrals?.total ?? 0}
</div>
</div>
<div className="ios26-panel" style={{ padding: 16 }}>
<div style={{ fontSize: 11, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 4 }}>
Заработано
</div>
<div style={{ fontSize: 16, fontWeight: 600, color: 'var(--md-sys-color-primary)' }}>
{formatCurrency(stats.earnings?.total ?? stats.bonus_account?.balance ?? 0)}
</div>
</div>
</div>
)}
{/* Список приглашённых рефералов */}
{referralsList && (referralsList.direct.length > 0 || referralsList.indirect.length > 0) && (
<div>
<div
style={{
fontSize: 11,
fontWeight: 600,
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 style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
Прямые рефералы ({referralsList.direct.length})
</div>
<ul className="referral-list" style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
{referralsList.direct.map((r: MyReferralItem, i: number) => (
<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)}
</li>
))}
</ul>
</div>
)}
{referralsList.indirect.length > 0 && (
<div>
<div style={{ fontSize: 12, color: 'var(--md-sys-color-on-surface-variant)', marginBottom: 6 }}>
Рефералы ваших рефералов ({referralsList.indirect.length})
</div>
<ul className="referral-list" style={{ margin: 0, paddingLeft: 20, listStyle: 'disc' }}>
{referralsList.indirect.map((r: MyReferralItem, i: number) => (
<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)}
</li>
))}
</ul>
</div>
)}
</div>
</div>
)}
{referralsList && referralsList.direct.length === 0 && referralsList.indirect.length === 0 && (
<p style={{ fontSize: 14, color: 'var(--md-sys-color-on-surface-variant)' }}>
Пока никого нет. Поделитесь реферальной ссылкой когда кто-то зарегистрируется по ней, он появится здесь и вы получите уведомление.
</p>
)}
</div>
);
}

View File

@ -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;
}

View File

@ -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

View File

@ -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