mobile
Deploy to Production / deploy-production (push) Successful in 26s
Details
Deploy to Production / deploy-production (push) Successful in 26s
Details
This commit is contained in:
parent
b4b99491ae
commit
d4c4dbb087
|
|
@ -99,9 +99,19 @@ class UpdateLastActivityMiddleware:
|
|||
cache.set(cache_key, now, timeout=self.UPDATE_INTERVAL * 2)
|
||||
|
||||
# Обновляем объект пользователя в запросе для текущего запроса
|
||||
# Это позволяет использовать обновленное значение в текущем запросе
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -309,9 +309,37 @@ class User(AbstractUser):
|
|||
def __str__(self):
|
||||
return f"{self.get_full_name()} ({self.email})"
|
||||
|
||||
def _generate_universal_code(self):
|
||||
"""Генерация уникального 8-символьного кода (цифры + латинские буквы A–Z)."""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
for _ in range(100):
|
||||
code = ''.join(random.choices(alphabet, k=8))
|
||||
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
|
||||
return code
|
||||
raise ValueError('Не удалось сгенерировать уникальный universal_code')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
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-символьного кода (цифры + латинские буквы A–Z)."""
|
||||
alphabet = string.ascii_uppercase + string.digits
|
||||
for _ in range(100):
|
||||
code = ''.join(random.choices(alphabet, k=8))
|
||||
if not User.objects.filter(universal_code=code).exclude(pk=self.pk).exists():
|
||||
return code
|
||||
raise ValueError('Не удалось сгенерировать уникальный universal_code')
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Переопределение 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):
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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', # Для загрузки файлов
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -234,6 +234,7 @@ export default function RegisterPage() {
|
|||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div
|
||||
className="auth-name-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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: 'Оценка (1–5)' } },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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: 'Оценка (1–5)' } },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</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
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
export const MOBILE_BREAKPOINT = 767;
|
||||
|
||||
export function useIsMobile(breakpoint: number = MOBILE_BREAKPOINT) {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
||||
setIsMobile(mq.matches);
|
||||
const listener = () => setIsMobile(mq.matches);
|
||||
mq.addEventListener('change', listener);
|
||||
return () => mq.removeEventListener('change', listener);
|
||||
}, [breakpoint]);
|
||||
return isMobile;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<svg width="512" height="512" viewBox="0 8 130 130" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect y="8" width="130" height="130" rx="20" fill="#7444FD"/>
|
||||
<path d="M31.8908 90.7V38H46.1708V87.555C46.1708 91.8617 47.2192 95.7717 49.3158 99.285C51.4125 102.798 54.2175 105.603 57.7308 107.7C61.3008 109.74 65.2108 110.76 69.4608 110.76C73.7675 110.76 77.6492 109.74 81.1058 107.7C84.6192 105.603 87.4242 102.798 89.5208 99.285C91.6175 95.7717 92.6658 91.8617 92.6658 87.555V38H106.946L107.031 123H92.7508L92.6658 112.205C89.6625 116.172 85.8658 119.345 81.2758 121.725C76.6858 124.048 71.7275 125.21 66.4008 125.21C60.0542 125.21 54.2458 123.68 48.9758 120.62C43.7625 117.503 39.5975 113.338 36.4808 108.125C33.4208 102.912 31.8908 97.1033 31.8908 90.7Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 782 B |
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "Uchill Platform",
|
||||
"short_name": "Uchill",
|
||||
"description": "Образовательная платформа",
|
||||
"start_url": "/dashboard",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#7444FD",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon.svg",
|
||||
"sizes": "any",
|
||||
"type": "image/svg+xml",
|
||||
"purpose": "any"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue