diff --git a/front_minimal/src/actions/calendar.js b/front_minimal/src/actions/calendar.js index 790d282..a5c1cf5 100644 --- a/front_minimal/src/actions/calendar.js +++ b/front_minimal/src/actions/calendar.js @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns'; import useSWR, { mutate } from 'swr'; @@ -10,11 +10,14 @@ import { updateCalendarLesson, deleteCalendarLesson, } from 'src/utils/dashboard-api'; +import { getGroups } from 'src/utils/groups-api'; +import { useAuthContext } from 'src/auth/hooks'; // ---------------------------------------------------------------------- const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200'; const SUBJECTS_ENDPOINT = '/schedule/subjects/'; +const GROUPS_ENDPOINT = '/groups/'; const swrOptions = { revalidateIfStale: true, @@ -36,9 +39,25 @@ export function useGetEvents(currentDate) { const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd'); const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd'); + const { user } = useAuthContext(); + + const getChildId = () => { + if (user?.role !== 'parent') return null; + try { const s = localStorage.getItem('selected_child'); return s ? (JSON.parse(s)?.id || null) : null; } catch { return null; } + }; + const [childId, setChildId] = useState(getChildId); + + useEffect(() => { + if (user?.role !== 'parent') return undefined; + const handler = () => setChildId(getChildId()); + window.addEventListener('child-changed', handler); + return () => window.removeEventListener('child-changed', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [user?.role]); + const { data: response, isLoading, error, isValidating } = useSWR( - ['calendar', start, end], - ([, s, e]) => getCalendarLessons(s, e), + ['calendar', start, end, childId], + ([, s, e, cid]) => getCalendarLessons(s, e, cid ? { child_id: cid } : undefined), swrOptions ); @@ -58,8 +77,10 @@ export function useGetEvents(currentDate) { : ''; const subject = lesson.subject_name || lesson.subject || 'Урок'; - const student = lesson.client_name || ''; - const displayTitle = `${startTimeStr} ${subject}${student ? ` - ${student}` : ''}`; + const participant = lesson.group_name + ? `Группа: ${lesson.group_name}` + : (lesson.client_name || ''); + const displayTitle = `${startTimeStr} ${subject}${participant ? ` - ${participant}` : ''}`; const status = String(lesson.status || 'scheduled').toLowerCase(); let eventColor = '#7635dc'; @@ -82,6 +103,8 @@ export function useGetEvents(currentDate) { status, student: lesson.client_name || '', mentor: lesson.mentor_name || '', + group: lesson.group || null, + group_name: lesson.group_name || '', }, }; }); @@ -134,6 +157,16 @@ export function useGetSubjects() { }, [response, isLoading, error]); } +export function useGetGroups() { + const { data, isLoading, error } = useSWR(GROUPS_ENDPOINT, getGroups, swrOptions); + + return useMemo(() => ({ + groups: Array.isArray(data) ? data : [], + groupsLoading: isLoading, + groupsError: error, + }), [data, isLoading, error]); +} + // ---------------------------------------------------------------------- function revalidateCalendar(date) { @@ -144,18 +177,16 @@ function revalidateCalendar(date) { } export async function createEvent(eventData, currentDate) { - const startTime = new Date(eventData.start_time); - const endTime = new Date(startTime.getTime() + (eventData.duration || 60) * 60000); - + const isGroup = !!eventData.group; const payload = { - client: String(eventData.client), title: eventData.title || 'Занятие', description: eventData.description || '', - start_time: startTime.toISOString(), - end_time: endTime.toISOString(), + start_time: eventData.start_time, + duration: eventData.duration || 60, price: eventData.price, is_recurring: eventData.is_recurring || false, ...(eventData.subject && { subject_id: Number(eventData.subject) }), + ...(isGroup ? { group: eventData.group } : { client: String(eventData.client) }), }; const res = await createCalendarLesson(payload); @@ -167,12 +198,8 @@ export async function updateEvent(eventData, currentDate) { const { id, ...data } = eventData; const updatePayload = {}; - if (data.start_time) { - const startTime = new Date(data.start_time); - const endTime = new Date(startTime.getTime() + (data.duration || 60) * 60000); - updatePayload.start_time = startTime.toISOString(); - updatePayload.end_time = endTime.toISOString(); - } + if (data.start_time) updatePayload.start_time = data.start_time; + if (data.duration) updatePayload.duration = data.duration; if (data.price != null) updatePayload.price = data.price; if (data.description != null) updatePayload.description = data.description; if (data.status) updatePayload.status = data.status; diff --git a/front_minimal/src/app/dashboard/lesson/[id]/page.jsx b/front_minimal/src/app/dashboard/lesson/[id]/page.jsx new file mode 100644 index 0000000..d3e404a --- /dev/null +++ b/front_minimal/src/app/dashboard/lesson/[id]/page.jsx @@ -0,0 +1,21 @@ +import { CONFIG } from 'src/config-global'; + +import { LessonDetailView } from 'src/sections/lesson-detail/view'; + +// ---------------------------------------------------------------------- + +export const metadata = { title: `Занятие | ${CONFIG.site.name}` }; + +export default function Page({ params }) { + return ; +} + +// ---------------------------------------------------------------------- + +const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic'; + +export { dynamic }; + +export async function generateStaticParams() { + return []; +} diff --git a/front_minimal/src/app/dashboard/page.jsx b/front_minimal/src/app/dashboard/page.jsx index 929223b..0b30407 100644 --- a/front_minimal/src/app/dashboard/page.jsx +++ b/front_minimal/src/app/dashboard/page.jsx @@ -1,4 +1,3 @@ - import { useState, useEffect } from 'react'; import Box from '@mui/material/Box'; @@ -6,29 +5,82 @@ import Typography from '@mui/material/Typography'; import CircularProgress from '@mui/material/CircularProgress'; import { useAuthContext } from 'src/auth/hooks'; +import axios from 'src/utils/axios'; import { OverviewCourseView } from 'src/sections/overview/course/view'; import { OverviewClientView } from 'src/sections/overview/client/view'; // ---------------------------------------------------------------------- +async function loadChildren() { + try { + const res = await axios.get('/parent/dashboard/'); + const raw = res.data?.children ?? []; + return raw.map((item) => { + const c = item.child ?? item; + return { id: c.id, name: c.name || c.email || '' }; + }); + } catch { + return []; + } +} + +// ---------------------------------------------------------------------- + export default function DashboardPage() { const { user, loading } = useAuthContext(); - // Для родителя: выбранный ребёнок из localStorage const [selectedChild, setSelectedChild] = useState(null); + const [childrenLoading, setChildrenLoading] = useState(false); + const [noChildren, setNoChildren] = useState(false); + // Load children for parent role useEffect(() => { - if (user?.role === 'parent') { + if (user?.role !== 'parent') return undefined; + + setChildrenLoading(true); + + loadChildren().then((list) => { + setChildrenLoading(false); + + if (!list.length) { + setNoChildren(true); + return; + } + + // Try to restore saved child + try { + const saved = localStorage.getItem('selected_child'); + if (saved) { + const parsed = JSON.parse(saved); + const exists = list.find((c) => c.id === parsed.id); + if (exists) { + setSelectedChild(parsed); + return; + } + } + } catch { /* ignore */ } + + // Auto-select first child + const first = list[0]; + localStorage.setItem('selected_child', JSON.stringify(first)); + window.dispatchEvent(new Event('child-changed')); + setSelectedChild(first); + }); + + // React to child switch from nav selector + const handler = () => { try { const saved = localStorage.getItem('selected_child'); if (saved) setSelectedChild(JSON.parse(saved)); - } catch { - // ignore - } - } + } catch { /* ignore */ } + }; + window.addEventListener('child-changed', handler); + return () => window.removeEventListener('child-changed', handler); }, [user]); + // ---------------------------------------------------------------------- + if (loading) { return ( @@ -39,26 +91,40 @@ export default function DashboardPage() { if (!user) return null; - if (user.role === 'mentor') { - return ; - } + if (user.role === 'mentor') return ; - if (user.role === 'client') { - return ; - } + if (user.role === 'client') return ; if (user.role === 'parent') { - return ( - - ); + if (childrenLoading) { + return ( + + + + ); + } + + if (noChildren) { + return ( + + Нет привязанных детей + + Обратитесь к администратору для привязки аккаунта ребёнка + + + ); + } + + if (!selectedChild) { + return ( + + + + ); + } + + return ; } - return ( - - Неизвестная роль: {user.role} - - ); + return null; } diff --git a/front_minimal/src/layouts/components/nav-upgrade.jsx b/front_minimal/src/layouts/components/nav-upgrade.jsx index 5145d91..b2e4b55 100644 --- a/front_minimal/src/layouts/components/nav-upgrade.jsx +++ b/front_minimal/src/layouts/components/nav-upgrade.jsx @@ -1,16 +1,26 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import Box from '@mui/material/Box'; +import Menu from '@mui/material/Menu'; import Stack from '@mui/material/Stack'; import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; import Tooltip from '@mui/material/Tooltip'; +import MenuItem from '@mui/material/MenuItem'; import Typography from '@mui/material/Typography'; import LinearProgress from '@mui/material/LinearProgress'; +import { useRouter } from 'src/routes/hooks'; +import { paths } from 'src/routes/paths'; + import { CONFIG } from 'src/config-global'; import { useAuthContext } from 'src/auth/hooks'; +import { signOut } from 'src/auth/context/jwt/action'; +import axios from 'src/utils/axios'; import { Label } from 'src/components/label'; +import { Iconify } from 'src/components/iconify'; // ---------------------------------------------------------------------- @@ -29,16 +39,174 @@ async function fetchActiveSubscription() { } } +async function fetchChildren() { + try { + const res = await axios.get('/parent/dashboard/'); + const raw = res.data?.children ?? []; + // Normalize: { child: {id, name, email} } → { id, first_name, last_name, email } + return raw.map((item) => { + const c = item.child ?? item; + const [first_name = '', ...rest] = (c.name || '').split(' '); + return { id: c.id, first_name, last_name: rest.join(' '), email: c.email }; + }); + } catch { + return []; + } +} + +// ---------------------------------------------------------------------- + +function ChildSelector({ children }) { + const [selected, setSelected] = useState(null); + const [anchorEl, setAnchorEl] = useState(null); + + useEffect(() => { + if (!children.length) return; + try { + const saved = localStorage.getItem('selected_child'); + if (saved) { + const parsed = JSON.parse(saved); + const exists = children.find((c) => c.id === parsed.id); + if (exists) { setSelected(parsed); return; } + } + } catch { /* ignore */ } + const first = children[0]; + const child = { + id: first.id, + name: `${first.first_name || ''} ${first.last_name || ''}`.trim() || first.email, + }; + localStorage.setItem('selected_child', JSON.stringify(child)); + window.dispatchEvent(new Event('child-changed')); + setSelected(child); + }, [children]); + + const handleSelect = (child) => { + const data = { + id: child.id, + name: `${child.first_name || ''} ${child.last_name || ''}`.trim() || child.email, + }; + localStorage.setItem('selected_child', JSON.stringify(data)); + window.dispatchEvent(new Event('child-changed')); + setSelected(data); + setAnchorEl(null); + }; + + if (!selected) return null; + + return ( + <> + + + Ученик + + + children.length > 0 && setAnchorEl(e.currentTarget)} + sx={{ + px: 1.5, + py: 1, + borderRadius: 1.5, + cursor: 'pointer', + border: '1px solid', + borderColor: 'var(--layout-nav-border-color)', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + {(selected.name[0] || '?').toUpperCase()} + + + {selected.name} + + + + + setAnchorEl(null)} + anchorOrigin={{ vertical: 'top', horizontal: 'left' }} + transformOrigin={{ vertical: 'bottom', horizontal: 'left' }} + slotProps={{ paper: { sx: { minWidth: 200 } } }} + > + {children.map((child) => { + const name = + `${child.first_name || ''} ${child.last_name || ''}`.trim() || child.email; + const isSelected = selected.id === child.id; + return ( + handleSelect(child)} + sx={{ gap: 1.5 }} + > + + {name[0]?.toUpperCase()} + + + + {name} + + {child.email && ( + + {child.email} + + )} + + {isSelected && ( + + )} + + ); + })} + + + + + + ); +} + // ---------------------------------------------------------------------- export function NavUpgrade({ sx, ...other }) { - const { user } = useAuthContext(); - const [sub, setSub] = useState(undefined); // undefined = loading, null = no sub + const { user, checkUserSession } = useAuthContext(); + const router = useRouter(); + + const handleLogout = useCallback(async () => { + await signOut(); + await checkUserSession(); + router.push(paths.auth.jwt.signIn); + }, [checkUserSession, router]); + + const [sub, setSub] = useState(undefined); + const [children, setChildren] = useState([]); useEffect(() => { if (!user?.id) return; - fetchActiveSubscription().then(setSub); - }, [user?.id]); + if (user.role !== 'client') fetchActiveSubscription().then(setSub); + if (user.role === 'parent') fetchChildren().then(setChildren); + }, [user?.id, user?.role]); const displayName = user ? `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email @@ -55,33 +223,27 @@ export function NavUpgrade({ sx, ...other }) { : `${(CONFIG.site.serverUrl || '').replace(/\/api\/?$/, '')}${user.avatar}` : null; - // Subscription label let labelColor = 'default'; let labelText = 'Нет подписки'; - if (sub === undefined) { labelText = '…'; } else if (sub && sub.is_active_now) { const planName = sub.plan?.name || 'Подписка'; - const status = sub.status; - labelText = status === 'trial' ? `Пробный: ${planName}` : planName; - labelColor = status === 'trial' ? 'warning' : 'success'; + labelText = sub.status === 'trial' ? `Пробный: ${planName}` : planName; + labelColor = sub.status === 'trial' ? 'warning' : 'success'; } - // End date display let endDateText = null; if (sub && sub.is_active_now) { const endField = sub.status === 'trial' ? sub.trial_end_date : sub.end_date; if (endField) { - const date = new Date(endField); - endDateText = `до ${date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })}`; + endDateText = `до ${new Date(endField).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })}`; } - if (sub.days_left !== undefined && sub.days_left !== null) { + if (sub.days_left != null) { endDateText = endDateText ? `${endDateText} (${sub.days_left} дн.)` : `${sub.days_left} дн.`; } } - // Progress bar for days left (out of 30) const daysProgress = sub?.days_left != null && sub?.is_active_now ? Math.min(100, Math.round((sub.days_left / 30) * 100)) @@ -94,12 +256,18 @@ export function NavUpgrade({ sx, ...other }) { py: 2.5, borderTop: '1px solid', borderColor: 'var(--layout-nav-border-color)', + gap: 1.5, ...sx, }} {...other} > + {/* Селектор ребёнка — только для родителя, над профилем */} + {user?.role === 'parent' && children.length > 0 && ( + + )} + {/* Avatar + name + email */} - + - {/* Subscription badge */} - - - - {endDateText && ( - - {endDateText} - + {/* Subscription badge — только для ментора и родителя */} + {user?.role !== 'client' && ( + + + + {endDateText && ( + + {endDateText} + + )} + + {daysProgress !== null && ( + + + )} + )} + + - {daysProgress !== null && ( - - - - )} - ); } // ---------------------------------------------------------------------- -// UpgradeBlock — оставляем для совместимости, больше не используется в платформе export function UpgradeBlock({ sx, ...other }) { return ; } diff --git a/front_minimal/src/layouts/config-nav-dashboard.jsx b/front_minimal/src/layouts/config-nav-dashboard.jsx index 7c4737e..20ee84c 100644 --- a/front_minimal/src/layouts/config-nav-dashboard.jsx +++ b/front_minimal/src/layouts/config-nav-dashboard.jsx @@ -7,15 +7,20 @@ import { SvgColor } from 'src/components/svg-color'; const icon = (name) => ; const ICONS = { - chat: icon('ic-chat'), - user: icon('ic-user'), - course: icon('ic-course'), - calendar: icon('ic-calendar'), dashboard: icon('ic-dashboard'), - kanban: icon('ic-kanban'), + user: icon('ic-user'), + calendar: icon('ic-calendar'), + booking: icon('ic-booking'), folder: icon('ic-folder'), + kanban: icon('ic-kanban'), + chat: icon('ic-chat'), + mail: icon('ic-mail'), analytics: icon('ic-analytics'), + course: icon('ic-course'), label: icon('ic-label'), + banking: icon('ic-banking'), + invoice: icon('ic-invoice'), + tour: icon('ic-tour'), }; // ---------------------------------------------------------------------- @@ -35,40 +40,52 @@ export function getNavData(role) { { subheader: 'Инструменты', items: [ - // Ученики/Менторы — для всех ролей (разный контент внутри) - { title: isMentor ? 'Ученики' : 'Менторы', path: paths.dashboard.students, icon: ICONS.user }, + // Ученики/Менторы — только для ментора и клиента (не для родителя) + ...(!isParent ? [ + { title: isMentor ? 'Ученики' : 'Менторы', path: paths.dashboard.students, icon: ICONS.user }, + ] : []), { title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar }, - { title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban }, + { title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.booking }, { title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder }, - { title: 'Доска', path: paths.dashboard.board, icon: ICONS.kanban }, + ...(!isParent ? [{ title: 'Доска', path: paths.dashboard.board, icon: ICONS.kanban }] : []), { title: 'Чат', path: paths.dashboard.chatPlatform, icon: ICONS.chat }, - { title: 'Уведомления', path: paths.dashboard.notifications, icon: ICONS.label }, + { title: 'Уведомления', path: paths.dashboard.notifications, icon: ICONS.mail }, // Ментор-специфичные ...(isMentor ? [ + { title: 'Группы', path: paths.dashboard.groups, icon: ICONS.tour }, { title: 'Аналитика', path: paths.dashboard.analytics, icon: ICONS.analytics }, - { title: 'Обратная связь', path: paths.dashboard.feedback, icon: icon('ic-label') }, + { title: 'Обратная связь', path: paths.dashboard.feedback, icon: ICONS.label }, ] : []), - // Клиент/Родитель - ...((isClient || isParent) ? [ + // Клиент + ...(isClient ? [ + { title: 'Мои группы', path: paths.dashboard.groups, icon: ICONS.tour }, { title: 'Мой прогресс', path: paths.dashboard.myProgress, icon: ICONS.course }, ] : []), + // Родитель + ...(isParent ? [ + { title: 'Прогресс', path: paths.dashboard.myProgress, icon: ICONS.course }, + ] : []), + // Родитель-специфичные ...(isParent ? [ { title: 'Дети', path: paths.dashboard.children, icon: ICONS.user }, - { title: 'Прогресс детей', path: paths.dashboard.childrenProgress, icon: ICONS.course }, ] : []), - { title: 'Оплата', path: paths.dashboard.payment, icon: ICONS.folder }, + ...(isMentor ? [ + { title: 'Оплата', path: paths.dashboard.payment, icon: ICONS.banking }, + ] : []), ], }, { subheader: 'Аккаунт', items: [ { title: 'Профиль', path: paths.dashboard.profile, icon: ICONS.user }, - { title: 'Рефералы', path: paths.dashboard.referrals, icon: ICONS.course }, + ...(isMentor ? [ + { title: 'Рефералы', path: paths.dashboard.referrals, icon: ICONS.invoice }, + ] : []), ], }, ]; diff --git a/front_minimal/src/layouts/core/layout-section.jsx b/front_minimal/src/layouts/core/layout-section.jsx index b7ad6dc..9d78ba9 100644 --- a/front_minimal/src/layouts/core/layout-section.jsx +++ b/front_minimal/src/layouts/core/layout-section.jsx @@ -34,7 +34,7 @@ export function LayoutSection({ <> {inputGlobalStyles} - + {sidebarSection ? ( <> {sidebarSection} @@ -42,6 +42,8 @@ export function LayoutSection({ display="flex" flex="1 1 auto" flexDirection="column" + minHeight={0} + overflow="auto" className={layoutClasses.hasSidebar} > {headerSection} diff --git a/front_minimal/src/layouts/dashboard/layout.jsx b/front_minimal/src/layouts/dashboard/layout.jsx index 870c992..f6aecea 100644 --- a/front_minimal/src/layouts/dashboard/layout.jsx +++ b/front_minimal/src/layouts/dashboard/layout.jsx @@ -161,12 +161,13 @@ export function DashboardLayout({ sx, children, data }) { isNavMini={isNavMini} layoutQuery={layoutQuery} cssVars={navColorVars.section} - onToggleNav={() => + onToggleNav={() => { settings.onUpdateField( 'navLayout', settings.navLayout === 'vertical' ? 'mini' : 'vertical' - ) - } + ); + setTimeout(() => window.dispatchEvent(new Event('resize')), 0); + }} /> ) } diff --git a/front_minimal/src/layouts/dashboard/main.jsx b/front_minimal/src/layouts/dashboard/main.jsx index 9d31856..80a32c6 100644 --- a/front_minimal/src/layouts/dashboard/main.jsx +++ b/front_minimal/src/layouts/dashboard/main.jsx @@ -16,6 +16,8 @@ export function Main({ children, isNavHorizontal, sx, ...other }) { display: 'flex', flex: '1 1 auto', flexDirection: 'column', + minHeight: 0, + overflow: 'auto', ...(isNavHorizontal && { '--layout-dashboard-content-pt': '40px', }), diff --git a/front_minimal/src/routes/paths.js b/front_minimal/src/routes/paths.js index 2c97d95..d4ca7cd 100644 --- a/front_minimal/src/routes/paths.js +++ b/front_minimal/src/routes/paths.js @@ -25,8 +25,10 @@ export const paths = { root: ROOTS.DASHBOARD, calendar: `${ROOTS.DASHBOARD}/schedule`, homework: `${ROOTS.DASHBOARD}/homework`, + homeworkDetail: (id) => `${ROOTS.DASHBOARD}/homework/${id}`, materials: `${ROOTS.DASHBOARD}/materials`, students: `${ROOTS.DASHBOARD}/students`, + studentDetail: (id) => `${ROOTS.DASHBOARD}/students/${id}`, notifications: `${ROOTS.DASHBOARD}/notifications`, board: `${ROOTS.DASHBOARD}/board`, chatPlatform: `${ROOTS.DASHBOARD}/chat-platform`, @@ -38,5 +40,9 @@ export const paths = { children: `${ROOTS.DASHBOARD}/children`, childrenProgress: `${ROOTS.DASHBOARD}/children-progress`, myProgress: `${ROOTS.DASHBOARD}/my-progress`, + prejoin: `${ROOTS.DASHBOARD}/prejoin`, + lesson: (id) => `${ROOTS.DASHBOARD}/lesson/${id}`, + groups: `${ROOTS.DASHBOARD}/groups`, + groupDetail: (id) => `${ROOTS.DASHBOARD}/groups/${id}`, }, }; diff --git a/front_minimal/src/routes/sections.jsx b/front_minimal/src/routes/sections.jsx index d7c17b4..6f77cc2 100644 --- a/front_minimal/src/routes/sections.jsx +++ b/front_minimal/src/routes/sections.jsx @@ -1,5 +1,5 @@ import { lazy, Suspense } from 'react'; -import { Navigate, useRoutes, Outlet } from 'react-router-dom'; +import { Navigate, useRoutes, useParams, Outlet } from 'react-router-dom'; import { AuthGuard } from 'src/auth/guard/auth-guard'; import { GuestGuard } from 'src/auth/guard/guest-guard'; @@ -52,6 +52,9 @@ const MaterialsView = lazy(() => const StudentsView = lazy(() => import('src/sections/students/view').then((m) => ({ default: m.StudentsView })) ); +const StudentDetailView = lazy(() => + import('src/sections/students/view/student-detail-view').then((m) => ({ default: m.StudentDetailView })) +); const NotificationsView = lazy(() => import('src/sections/notifications/view').then((m) => ({ default: m.NotificationsView })) ); @@ -85,6 +88,18 @@ const ChildrenProgressView = lazy(() => const MyProgressView = lazy(() => import('src/sections/my-progress/view').then((m) => ({ default: m.MyProgressView })) ); +const LessonDetailView = lazy(() => + import('src/sections/lesson-detail/view').then((m) => ({ default: m.LessonDetailView })) +); +const HomeworkDetailView = lazy(() => + import('src/sections/homework/view').then((m) => ({ default: m.HomeworkDetailView })) +); +const GroupsView = lazy(() => + import('src/sections/groups/view').then((m) => ({ default: m.GroupsView })) +); +const GroupDetailView = lazy(() => + import('src/sections/groups/view').then((m) => ({ default: m.GroupDetailView })) +); // ---------------------------------------------------------------------- // Video call (fullscreen, no dashboard layout) @@ -93,6 +108,10 @@ const VideoCallView = lazy(() => import('src/sections/video-call/view').then((m) => ({ default: m.VideoCallView })) ); +const PrejoinView = lazy(() => + import('src/sections/prejoin/view/prejoin-view').then((m) => ({ default: m.PrejoinView })) +); + // ---------------------------------------------------------------------- // Error pages @@ -126,6 +145,16 @@ function AuthLayoutWrapper() { ); } +function LessonDetailWrapper() { + const { id } = useParams(); + return ; +} + +function HomeworkDetailWrapper() { + const { id } = useParams(); + return ; +} + // ---------------------------------------------------------------------- export function Router() { @@ -166,6 +195,7 @@ export function Router() { { path: 'homework', element: }, { path: 'materials', element: }, { path: 'students', element: }, + { path: 'students/:clientId', element: }, { path: 'notifications', element: }, { path: 'board', element: }, { path: 'chat-platform', element: }, @@ -177,6 +207,11 @@ export function Router() { { path: 'children', element: }, { path: 'children-progress', element: }, { path: 'my-progress', element: }, + { path: 'prejoin', element: }, + { path: 'lesson/:id', element: }, + { path: 'homework/:id', element: }, + { path: 'groups', element: }, + { path: 'groups/:id', element: }, ], }, diff --git a/front_minimal/src/sections/account-platform/view/account-platform-view.jsx b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx index 8ac0940..badd797 100644 --- a/front_minimal/src/sections/account-platform/view/account-platform-view.jsx +++ b/front_minimal/src/sections/account-platform/view/account-platform-view.jsx @@ -34,6 +34,7 @@ import CircularProgress from '@mui/material/CircularProgress'; import { paths } from 'src/routes/paths'; +import axios, { resolveMediaUrl } from 'src/utils/axios'; import { unlinkTelegram, getTelegramStatus, getTelegramBotInfo, generateTelegramCode } from 'src/utils/telegram-api'; import { searchCities, @@ -46,7 +47,6 @@ import { updateNotificationPreferences, } from 'src/utils/profile-api'; -import { CONFIG } from 'src/config-global'; import { DashboardContent } from 'src/layouts/dashboard'; import { Iconify } from 'src/components/iconify'; @@ -87,12 +87,7 @@ const CHANNELS = [ // ---------------------------------------------------------------------- -function avatarSrc(src) { - if (!src) return ''; - if (src.startsWith('http://') || src.startsWith('https://')) return src; - const base = CONFIG.site.serverUrl?.replace('/api', '') || ''; - return base + (src.startsWith('/') ? src : `/${src}`); -} +const avatarSrc = (src) => resolveMediaUrl(src); // ---------------------------------------------------------------------- @@ -322,6 +317,154 @@ function TelegramSection({ onAvatarLoaded }) { // ---------------------------------------------------------------------- +const CHILD_NOTIFICATION_TYPES = [ + { value: 'lesson_created', label: 'Создано занятие' }, + { value: 'lesson_updated', label: 'Занятие обновлено' }, + { value: 'lesson_cancelled', label: 'Занятие отменено' }, + { value: 'lesson_rescheduled', label: 'Занятие перенесено' }, + { value: 'lesson_reminder', label: 'Напоминание о занятии' }, + { value: 'lesson_completed', label: 'Занятие завершено' }, + { value: 'homework_assigned', label: 'Назначено домашнее задание' }, + { value: 'homework_submitted', label: 'ДЗ сдано' }, + { value: 'homework_reviewed', label: 'ДЗ проверено' }, + { value: 'homework_returned', label: 'ДЗ возвращено на доработку' }, + { value: 'homework_deadline_reminder', label: 'Напоминание о дедлайне ДЗ' }, + { value: 'material_added', label: 'Добавлен материал' }, +]; + +function ParentChildNotifications() { + const [children, setChildren] = useState([]); + const [settings, setSettings] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState({}); + const [expanded, setExpanded] = useState(null); + const [error, setError] = useState(''); + + useEffect(() => { + const load = async () => { + try { + setLoading(true); + const res = await axios.get('/parent/dashboard/'); + const raw = res.data?.children ?? []; + const list = raw.map((item) => { + const c = item.child ?? item; + return { id: String(c.id), name: c.name || c.email || 'Ребёнок', avatar_url: c.avatar_url }; + }); + setChildren(list); + + const map = {}; + await Promise.all(list.map(async (child) => { + try { + const r = await axios.get(`/notifications/parent-child-settings/for_child/?child_id=${child.id}`); + map[child.id] = r.data; + } catch { + map[child.id] = { enabled: true, type_settings: {} }; + } + })); + setSettings(map); + } catch { + setError('Не удалось загрузить настройки уведомлений для детей'); + } finally { + setLoading(false); + } + }; + load(); + }, []); + + const patch = async (childId, payload) => { + try { + setSaving((p) => ({ ...p, [childId]: true })); + const r = await axios.patch(`/notifications/parent-child-settings/for_child/?child_id=${childId}`, payload); + setSettings((p) => ({ ...p, [childId]: r.data })); + } catch { + setError('Не удалось сохранить'); + } finally { + setSaving((p) => ({ ...p, [childId]: false })); + } + }; + + if (loading) return ; + if (children.length === 0) return null; + + return ( + + + Уведомления по детям + {error && {error}} + + {children.map((child) => { + const s = settings[child.id] || { enabled: true, type_settings: {} }; + const isExpanded = expanded === child.id; + return ( + + {/* Header row */} + setExpanded(isExpanded ? null : child.id)} + > + + + {child.name[0]?.toUpperCase()} + + + {child.name} + + {s.enabled ? 'Включены' : 'Выключены'} + + + + e.stopPropagation()}> + {saving[child.id] && } + patch(child.id, { enabled: !s.enabled })} + disabled={saving[child.id]} + /> + { e.stopPropagation(); setExpanded(isExpanded ? null : child.id); }} + /> + + + + {/* Expanded type list */} + {isExpanded && ( + + + {CHILD_NOTIFICATION_TYPES.map((type) => { + const isOn = s.type_settings[type.value] !== false; + return ( + + {type.label} + patch(child.id, { type_settings: { ...s.type_settings, [type.value]: !isOn } })} + /> + + ); + })} + + + )} + + ); + })} + + + + ); +} + +// ---------------------------------------------------------------------- + function NotificationMatrix({ prefs, onChange, role }) { const visibleTypes = role === 'parent' ? NOTIFICATION_TYPES.filter((t) => !PARENT_EXCLUDED_TYPES.includes(t.key)) @@ -395,7 +538,6 @@ export function AccountPlatformView() { // Profile fields const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); - const [phone, setPhone] = useState(''); const [email, setEmail] = useState(''); const [avatarPreview, setAvatarPreview] = useState(null); const [avatarHovered, setAvatarHovered] = useState(false); @@ -429,7 +571,6 @@ export function AccountPlatformView() { if (user) { setFirstName(user.first_name || ''); setLastName(user.last_name || ''); - setPhone(user.phone || ''); setEmail(user.email || ''); } }, [user]); @@ -710,13 +851,6 @@ export function AccountPlatformView() { fullWidth /> - setPhone(e.target.value)} - onBlur={(e) => handleProfileBlur('phone', e.target.value.trim())} - fullWidth - /> + {/* Per-child notification settings — parent only */} + {user?.role === 'parent' && } + {/* AI homework settings (mentor only) */} {user?.role === 'mentor' && settings && ( diff --git a/front_minimal/src/sections/calendar/view/calendar-view.jsx b/front_minimal/src/sections/calendar/view/calendar-view.jsx index 8ce1d78..4d9a89b 100644 --- a/front_minimal/src/sections/calendar/view/calendar-view.jsx +++ b/front_minimal/src/sections/calendar/view/calendar-view.jsx @@ -7,18 +7,37 @@ import listPlugin from '@fullcalendar/list'; import timelinePlugin from '@fullcalendar/timeline'; import ruLocale from '@fullcalendar/core/locales/ru'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useRef, useEffect, useCallback, useMemo } from 'react'; +import Box from '@mui/material/Box'; import Card from '@mui/material/Card'; +import Chip from '@mui/material/Chip'; import Stack from '@mui/material/Stack'; +import Alert from '@mui/material/Alert'; +import Avatar from '@mui/material/Avatar'; import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import Switch from '@mui/material/Switch'; +import Rating from '@mui/material/Rating'; +import Divider from '@mui/material/Divider'; +import TextField from '@mui/material/TextField'; import Container from '@mui/material/Container'; import Typography from '@mui/material/Typography'; +import DialogTitle from '@mui/material/DialogTitle'; +import FormControl from '@mui/material/FormControl'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import CircularProgress from '@mui/material/CircularProgress'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; import { useAuthContext } from 'src/auth/hooks'; import { useBoolean } from 'src/hooks/use-boolean'; import { useGetEvents, updateEvent, createEvent, deleteEvent } from 'src/actions/calendar'; +import { createLiveKitRoom } from 'src/utils/livekit-api'; +import { completeLesson, uploadLessonFile } from 'src/utils/dashboard-api'; +import { createHomework } from 'src/utils/homework-api'; import { Iconify } from 'src/components/iconify'; import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; @@ -31,15 +50,466 @@ import { useCalendar } from '../hooks/use-calendar'; // ---------------------------------------------------------------------- +const MAX_HW_FILES = 10; +const MAX_HW_FILE_MB = 10; + +function defaultDeadline() { + const d = new Date(); + d.setDate(d.getDate() + 10); + return d.toISOString().slice(0, 10); +} + +// Controlled: lessonId + open + onClose props. +// Uncontrolled: reads lessonId from sessionStorage on mount. +function CompleteLessonDialog({ lessonId: propLessonId, open: propOpen, onClose: propOnClose }) { + const isControlled = propLessonId != null; + + const [ssLessonId, setSsLessonId] = useState(null); + const [ssOpen, setSsOpen] = useState(false); + + const [mentorGrade, setMentorGrade] = useState(0); + const [schoolGrade, setSchoolGrade] = useState(0); + const [notes, setNotes] = useState(''); + const [hasHw, setHasHw] = useState(false); + const [hwTitle, setHwTitle] = useState('Домашнее задание'); + const [hwText, setHwText] = useState(''); + const [hwFiles, setHwFiles] = useState([]); + const [hwDeadline, setHwDeadline] = useState(defaultDeadline); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + + // Uncontrolled: check sessionStorage once on mount + useEffect(() => { + if (isControlled) return; + try { + const id = sessionStorage.getItem('complete_lesson_id'); + if (id) { + setSsLessonId(parseInt(id, 10)); + setSsOpen(true); + sessionStorage.removeItem('complete_lesson_id'); + } + } catch { /* ignore */ } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Reset form when a new lesson is opened + useEffect(() => { + const willOpen = isControlled ? propOpen : ssOpen; + if (willOpen) { + setMentorGrade(0); + setSchoolGrade(0); + setNotes(''); + setHasHw(false); + setHwTitle('Домашнее задание'); + setHwText(''); + setHwFiles([]); + setHwDeadline(defaultDeadline()); + setError(null); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isControlled ? propOpen : ssOpen, isControlled ? propLessonId : ssLessonId]); + + const lessonId = isControlled ? propLessonId : ssLessonId; + const open = isControlled ? (propOpen ?? false) : ssOpen; + + const handleClose = () => { + if (loading) return; + if (isControlled) propOnClose?.(); + else setSsOpen(false); + }; + + const handleFileChange = (e) => { + const { files: list } = e.target; + if (!list?.length) return; + const toAdd = Array.from(list).filter((f) => f.size <= MAX_HW_FILE_MB * 1024 * 1024); + setHwFiles((prev) => [...prev, ...toAdd].slice(0, MAX_HW_FILES)); + e.target.value = ''; + }; + + const handleSubmit = async () => { + if (!lessonId) return; + setLoading(true); + setError(null); + try { + const mg = mentorGrade > 0 ? mentorGrade : undefined; + const sg = schoolGrade > 0 ? schoolGrade : undefined; + const n = notes.trim() || ''; + + let fileIds = []; + if (hasHw && hwFiles.length > 0) { + // eslint-disable-next-line no-restricted-syntax + for (const file of hwFiles) { + // eslint-disable-next-line no-await-in-loop + const created = await uploadLessonFile(lessonId, file); + const fid = typeof created.id === 'number' ? created.id : parseInt(String(created.id), 10); + if (!Number.isNaN(fid)) fileIds.push(fid); + } + } + + const hwT = hasHw ? hwText.trim() || undefined : undefined; + await completeLesson(String(lessonId), n, mg, sg, hwT, hasHw && fileIds.length > 0, fileIds.length > 0 ? fileIds : undefined); + + if (hasHw && (hwText.trim() || hwFiles.length > 0)) { + await createHomework({ + title: hwTitle.trim() || 'Домашнее задание', + description: hwText.trim(), + lesson_id: lessonId, + due_date: hwDeadline, + status: 'published', + }); + } + + handleClose(); + } catch (e) { + setError(e?.response?.data?.detail || e?.message || 'Не удалось сохранить данные урока'); + } finally { + setLoading(false); + } + }; + + if (!open) return null; + + return ( + { if (reason !== 'backdropClick') handleClose(); }} + maxWidth="sm" + fullWidth + disableEscapeKeyDown + > + + Завершение урока + + Заполните информацию о прошедшем занятии + + + + + {error && {error}} + + {/* Grades */} + + Оценка за занятие + + + Успеваемость (1–5) + setMentorGrade(v ?? 0)} + disabled={loading} + /> + + + Школьная оценка (1–5) + setSchoolGrade(v ?? 0)} + disabled={loading} + /> + + + + + + + {/* Comment */} + setNotes(e.target.value)} + multiline + rows={3} + disabled={loading} + fullWidth + /> + + + + {/* Homework toggle */} + + setHasHw(e.target.checked)} + disabled={loading} + /> + } + label="Выдать домашнее задание" + /> + + + {/* Homework form */} + {hasHw && ( + + setHwTitle(e.target.value)} + disabled={loading} + fullWidth + size="small" + /> + setHwText(e.target.value)} + multiline + rows={3} + disabled={loading} + fullWidth + /> + setHwDeadline(e.target.value)} + disabled={loading} + fullWidth + size="small" + InputLabelProps={{ shrink: true }} + inputProps={{ min: new Date().toISOString().slice(0, 10) }} + /> + {/* File upload */} + + + + {hwFiles.length > 0 && ( + + {hwFiles.map((f, i) => ( + + + {f.name} + + + ))} + + )} + + + )} + + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +function fTime(str, timezone) { + if (!str) return ''; + const opts = { hour: '2-digit', minute: '2-digit', ...(timezone ? { timeZone: timezone } : {}) }; + return new Date(str).toLocaleTimeString('ru-RU', opts); +} + +function UpcomingLessonsBar({ events, isMentor, timezone }) { + const router = useRouter(); + const [joiningId, setJoiningId] = useState(null); + + const now = new Date(); + const todayKey = now.toDateString(); + + // Занятия сегодня, ещё не завершённые (не завершено и не отменено), сортируем по времени + const todayUpcoming = useMemo(() => { + return events + .filter((ev) => { + const st = new Date(ev.start); + const status = String(ev.extendedProps?.status || '').toLowerCase(); + return st.toDateString() === todayKey && status !== 'completed' && status !== 'cancelled'; + }) + .sort((a, b) => new Date(a.start) - new Date(b.start)) + .slice(0, 3); + }, [events, todayKey]); + + if (todayUpcoming.length === 0) return null; + + const handleJoin = async (ev) => { + setJoiningId(ev.id); + try { + const room = await createLiveKitRoom(ev.id); + const token = room.access_token || room.token; + router.push(`${paths.dashboard.prejoin}?token=${encodeURIComponent(token)}&lesson_id=${ev.id}`); + } catch (e) { + console.error(e); + } finally { + setJoiningId(null); + } + }; + + return ( + + + Сегодня · {todayUpcoming.length} {todayUpcoming.length === 1 ? 'занятие' : 'занятий'} + + + {todayUpcoming.map((ev) => { + const ep = ev.extendedProps || {}; + const startTime = new Date(ev.start); + const diffMin = (startTime - now) / 60000; + const canJoin = diffMin < 11 && diffMin > -90; + const isJoining = joiningId === ev.id; + + // Mentor sees student name; student sees mentor name + const personName = isMentor + ? (ep.student || ep.client_name || '') + : (ep.mentor_name || ep.mentor || ''); + const subject = ep.subject_name || ep.subject || ev.title || 'Занятие'; + const initial = (personName[0] || subject[0] || 'З').toUpperCase(); + + return ( + !canJoin && router.push(paths.dashboard.lesson(ev.id))} + sx={{ + p: 2, + display: 'flex', + alignItems: 'center', + gap: 1.5, + border: '1px solid', + borderColor: canJoin ? 'primary.main' : 'divider', + boxShadow: canJoin ? (t) => `0 0 0 2px ${t.vars.palette.primary.main}22` : 0, + transition: 'all 0.2s', + cursor: canJoin ? 'default' : 'pointer', + '&:hover': !canJoin ? { bgcolor: 'action.hover' } : {}, + }} + > + + {initial} + + + {subject} + {personName && ( + {personName} + )} + } + sx={{ mt: 0.5, height: 20, fontSize: 11 }} + /> + + {canJoin && ( + + )} + + ); + })} + + + ); +} + +// ---------------------------------------------------------------------- + +// Statuses that should open the detail page instead of the edit form +const NON_PLANNED_STATUSES = ['completed', 'in_progress', 'ongoing', 'cancelled']; + export function CalendarView() { const settings = useSettingsContext(); + const router = useRouter(); const { user } = useAuthContext(); const isMentor = user?.role === 'mentor'; + const [completeLessonId, setCompleteLessonId] = useState(null); + const [completeLessonOpen, setCompleteLessonOpen] = useState(false); + + const handleOpenCompleteLesson = useCallback((lessonId) => { + setCompleteLessonId(lessonId); + setCompleteLessonOpen(true); + }, []); + const { calendarRef, view, date, onDatePrev, onDateNext, onDateToday, onChangeView, onSelectRange, onClickEvent, onResizeEvent, onDropEvent, onInitialView, openForm, onOpenForm, onCloseForm, selectEventId, selectedRange } = useCalendar(); + // Intercept event click: + // - completed/cancelled/ongoing → detail page + // - past lessons (end_time already passed) → detail page regardless of status + // - future planned → mentor gets edit form, student gets detail page + const handleEventClick = useCallback((arg) => { + const eventId = arg.event.id; + if (!eventId || eventId === 'undefined' || eventId === 'null') return; + + const ep = arg.event.extendedProps || {}; + const status = String(ep.status || '').toLowerCase(); + + // Use raw API timestamps from extendedProps (more reliable than FullCalendar Date objects) + const rawEnd = ep.end_time || ep.end; + const rawStart = ep.start_time || ep.start; + const refTime = rawEnd || rawStart; + const isPast = refTime ? new Date(refTime).getTime() < Date.now() : false; + + if (NON_PLANNED_STATUSES.includes(status) || isPast) { + router.push(paths.dashboard.lesson(eventId)); + } else if (isMentor) { + onClickEvent(arg); + } else { + // student/parent — open detail page for upcoming lessons too + router.push(paths.dashboard.lesson(eventId)); + } + }, [router, isMentor, onClickEvent]); + const { events, eventsLoading } = useGetEvents(date); useEffect(() => { @@ -91,6 +561,8 @@ export function CalendarView() { )} + + { + const done = (e) => { + const s = String(e.extendedProps?.status || '').toLowerCase(); + return s === 'completed' || s === 'cancelled' ? 1 : 0; + }; + return done(a) - done(b); + }} plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, timelinePlugin]} locale={ruLocale} + timeZone={user?.timezone || 'local'} slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }} eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }} /> @@ -141,6 +621,19 @@ export function CalendarView() { onCreateEvent={handleCreateEvent} onUpdateEvent={handleUpdateEvent} onDeleteEvent={handleDeleteEvent} + onCompleteLesson={(lessonId) => { onCloseForm(); handleOpenCompleteLesson(lessonId); }} + /> + )} + + {/* Auto-open after video call redirect (reads sessionStorage) */} + {isMentor && } + + {/* Controlled: opened from CalendarForm */} + {isMentor && ( + setCompleteLessonOpen(false)} /> )} diff --git a/front_minimal/src/sections/chat/chat-window.jsx b/front_minimal/src/sections/chat/chat-window.jsx new file mode 100644 index 0000000..1982040 --- /dev/null +++ b/front_minimal/src/sections/chat/chat-window.jsx @@ -0,0 +1,430 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Alert from '@mui/material/Alert'; +import Stack from '@mui/material/Stack'; +import Avatar from '@mui/material/Avatar'; +import Divider from '@mui/material/Divider'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { useChatWebSocket } from 'src/hooks/use-chat-websocket'; +import { + getMessages, + sendMessage, + markMessagesAsRead, + getChatMessagesByUuid, +} from 'src/utils/chat-api'; + +import { Iconify } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +export function formatChatTime(ts) { + try { + if (!ts) return ''; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + } catch { + return ''; + } +} + +export function dateKey(ts) { + if (!ts) return ''; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +export function formatDayHeader(ts) { + if (!ts) return ''; + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + const now = new Date(); + const todayKey = dateKey(now.toISOString()); + const yKey = dateKey(new Date(now.getTime() - 86400000).toISOString()); + const k = dateKey(ts); + if (k === todayKey) return 'Сегодня'; + if (k === yKey) return 'Вчера'; + return `${String(d.getDate()).padStart(2, '0')}.${String(d.getMonth() + 1).padStart(2, '0')}.${d.getFullYear()}`; +} + +export function getChatInitials(name) { + return (name || 'Ч') + .trim() + .split(/\s+/) + .slice(0, 2) + .map((p) => p[0]) + .join('') + .toUpperCase(); +} + +export function stripHtml(s) { + if (typeof s !== 'string') return ''; + return s.replace(/<[^>]*>/g, '').trim(); +} + +// ---------------------------------------------------------------------- + +export function ChatWindow({ chat, currentUserId, onBack, hideHeader }) { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [text, setText] = useState(''); + const [sending, setSending] = useState(false); + const [sendError, setSendError] = useState(null); + const listRef = useRef(null); + const markedRef = useRef(new Set()); + const lastSentRef = useRef(null); + const lastWheelUpRef = useRef(0); + + const chatUuid = chat?.uuid || null; + + useChatWebSocket({ + chatUuid, + enabled: !!chatUuid, + onMessage: (m) => { + const chatId = chat?.id != null ? Number(chat.id) : null; + const msgChatId = m.chat != null ? Number(m.chat) : null; + if (chatId == null || msgChatId !== chatId) return; + const mid = m.id; + const muuid = m.uuid; + const sent = lastSentRef.current; + if ( + sent && + (String(mid) === String(sent.id) || + (muuid != null && sent.uuid != null && String(muuid) === String(sent.uuid))) + ) { + lastSentRef.current = null; + return; + } + setMessages((prev) => { + const isDuplicate = prev.some((x) => { + const sameId = mid != null && x.id != null && String(x.id) === String(mid); + const sameUuid = muuid != null && x.uuid != null && String(x.uuid) === String(muuid); + return sameId || sameUuid; + }); + if (isDuplicate) return prev; + return [...prev, m]; + }); + }, + }); + + useEffect(() => { + if (!chat) return undefined; + setLoading(true); + setPage(1); + setHasMore(false); + markedRef.current = new Set(); + lastSentRef.current = null; + + const fetchMessages = async () => { + try { + const PAGE_SIZE = 30; + const resp = chatUuid + ? await getChatMessagesByUuid(chatUuid, { page: 1, page_size: PAGE_SIZE }) + : await getMessages(chat.id, { page: 1, page_size: PAGE_SIZE }); + const sorted = (resp.results || []).slice().sort((a, b) => { + const ta = a.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b.created_at ? new Date(b.created_at).getTime() : 0; + return ta - tb; + }); + setMessages(sorted); + setHasMore(!!resp.next || (resp.count ?? 0) > sorted.length); + requestAnimationFrame(() => { + const el = listRef.current; + if (el) el.scrollTop = el.scrollHeight; + }); + } finally { + setLoading(false); + } + }; + fetchMessages(); + return undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chat?.id, chatUuid]); + + useEffect(() => { + if (!chatUuid || !listRef.current || messages.length === 0) return undefined; + const container = listRef.current; + const observer = new IntersectionObserver( + (entries) => { + const toMark = []; + entries.forEach((e) => { + if (!e.isIntersecting) return; + const uuid = e.target.getAttribute('data-message-uuid'); + const isMine = e.target.getAttribute('data-is-mine') === 'true'; + if (uuid && !isMine && !markedRef.current.has(uuid)) { + toMark.push(uuid); + markedRef.current.add(uuid); + } + }); + if (toMark.length > 0) { + markMessagesAsRead(chatUuid, toMark).catch(() => {}); + } + }, + { root: container, threshold: 0.5 } + ); + container.querySelectorAll('[data-message-uuid]').forEach((n) => observer.observe(n)); + return () => observer.disconnect(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chatUuid, messages]); + + const loadOlder = useCallback(async () => { + if (!chat || loading || loadingMore || !hasMore) return; + const container = listRef.current; + if (!container) return; + setLoadingMore(true); + const prevScrollHeight = container.scrollHeight; + const prevScrollTop = container.scrollTop; + try { + const nextPage = page + 1; + const PAGE_SIZE = 30; + const resp = chatUuid + ? await getChatMessagesByUuid(chatUuid, { page: nextPage, page_size: PAGE_SIZE }) + : await getMessages(chat.id, { page: nextPage, page_size: PAGE_SIZE }); + const batch = (resp.results || []).slice().sort((a, b) => { + const ta = a.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b.created_at ? new Date(b.created_at).getTime() : 0; + return ta - tb; + }); + setMessages((prev) => { + const keys = new Set(prev.map((m) => m.uuid || m.id)); + const toAdd = batch.filter((m) => !keys.has(m.uuid || m.id)); + return [...toAdd, ...prev].sort((a, b) => { + const ta = a.created_at ? new Date(a.created_at).getTime() : 0; + const tb = b.created_at ? new Date(b.created_at).getTime() : 0; + return ta - tb; + }); + }); + setPage(nextPage); + setHasMore(!!resp.next); + } finally { + setTimeout(() => { + const c = listRef.current; + if (!c) return; + c.scrollTop = prevScrollTop + (c.scrollHeight - prevScrollHeight); + }, 0); + setLoadingMore(false); + } + }, [chat, chatUuid, hasMore, loading, loadingMore, page]); + + const handleSend = async () => { + if (!chat || !text.trim() || sending) return; + const content = text.trim(); + setText(''); + setSendError(null); + setSending(true); + try { + const msg = await sendMessage(chat.id, content); + lastSentRef.current = { id: msg.id, uuid: msg.uuid }; + const safeMsg = { ...msg, created_at: msg.created_at || new Date().toISOString() }; + setMessages((prev) => [...prev, safeMsg]); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const el = listRef.current; + if (el) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + }); + }); + } catch (e) { + setText(content); + const errMsg = + e?.response?.data?.error?.message || + e?.response?.data?.detail || + e?.message || + 'Ошибка отправки'; + setSendError(errMsg); + } finally { + setSending(false); + } + }; + + if (!chat) { + return ( + + Выберите чат из списка + + ); + } + + const seen = new Set(); + const uniqueMessages = messages.filter((m) => { + const k = String(m.uuid ?? m.id ?? ''); + if (!k || seen.has(k)) return false; + seen.add(k); + return true; + }); + + const grouped = []; + let prevDay = ''; + uniqueMessages.forEach((m, idx) => { + const day = dateKey(m.created_at); + if (day && day !== prevDay) { + grouped.push({ type: 'day', key: `day-${day}`, label: formatDayHeader(m.created_at) }); + prevDay = day; + } + const senderId = m.sender_id ?? (typeof m.sender === 'number' ? m.sender : m.sender?.id ?? null); + const isMine = !!currentUserId && senderId === currentUserId; + const isSystem = + m.message_type === 'system' || + (typeof m.sender === 'string' && m.sender.toLowerCase() === 'system') || + (!senderId && m.sender_name === 'System'); + grouped.push({ + type: 'msg', + key: m.uuid || m.id || `msg-${idx}`, + msg: m, + isMine, + isSystem, + }); + }); + + return ( + + {/* Header */} + {!hideHeader && ( + + {onBack && ( + + + + )} + {getChatInitials(chat.participant_name)} + + {chat.participant_name || 'Чат'} + {chat.chat_type === 'group' ? ( + + {chat.participants_count ? `${chat.participants_count} участника(ов)` : 'Группа'} + + ) : ( + chat.other_is_online && ( + + Онлайн + + ) + )} + + + )} + + {/* Messages */} + { + if (e.deltaY < 0) lastWheelUpRef.current = Date.now(); + }} + onScroll={(e) => { + const el = e.currentTarget; + if (el.scrollTop < 40 && Date.now() - lastWheelUpRef.current < 200) loadOlder(); + }} + > + {loadingMore && ( + + Загрузка… + + )} + {loading ? ( + + + + ) : ( + grouped.map((item) => { + if (item.type === 'day') { + return ( + + + {item.label} + + + ); + } + const { msg, isMine, isSystem } = item; + const msgUuid = msg.uuid ? String(msg.uuid) : null; + const isGroup = chat?.chat_type === 'group'; + const senderName = + !isMine && !isSystem && isGroup + ? (msg.sender?.full_name || + [msg.sender?.first_name, msg.sender?.last_name].filter(Boolean).join(' ') || + msg.sender_name || + null) + : null; + return ( + + {senderName && ( + + {senderName} + + )} + + {stripHtml(msg.content || '')} + + + {formatChatTime(msg.created_at)} + + + ); + }) + )} + + + {/* Input */} + + {sendError && ( + setSendError(null)} sx={{ mx: 1.5, mt: 1, borderRadius: 1 }}> + {sendError} + + )} + + setText(e.target.value)} + placeholder="Сообщение…" + fullWidth + multiline + minRows={1} + maxRows={4} + size="small" + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }} + /> + + {sending ? : } + + + + ); +} diff --git a/front_minimal/src/sections/children/view/children-view.jsx b/front_minimal/src/sections/children/view/children-view.jsx index 39a3497..9684224 100644 --- a/front_minimal/src/sections/children/view/children-view.jsx +++ b/front_minimal/src/sections/children/view/children-view.jsx @@ -1,7 +1,7 @@ 'use client'; -import { useRouter } from 'src/routes/hooks'; -import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'src/routes/hooks'; +import { useState, useEffect, useCallback } from 'react'; import Box from '@mui/material/Box'; import Card from '@mui/material/Card'; @@ -10,16 +10,22 @@ import Stack from '@mui/material/Stack'; import Alert from '@mui/material/Alert'; import Avatar from '@mui/material/Avatar'; import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import TextField from '@mui/material/TextField'; import Typography from '@mui/material/Typography'; import CardContent from '@mui/material/CardContent'; -import CardActions from '@mui/material/CardActions'; +import CardActions from '@mui/material/CardActions'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; import CircularProgress from '@mui/material/CircularProgress'; +import InputAdornment from '@mui/material/InputAdornment'; import { paths } from 'src/routes/paths'; import axios from 'src/utils/axios'; -import { DashboardContent } from 'src/layouts/dashboard'; +import { DashboardContent } from 'src/layouts/dashboard'; import { Iconify } from 'src/components/iconify'; import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; @@ -27,10 +33,95 @@ import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; // ---------------------------------------------------------------------- async function getChildren() { - const res = await axios.get('/users/parents/children/'); - const {data} = res; - if (Array.isArray(data)) return data; - return data?.results ?? []; + const res = await axios.get('/parent/dashboard/'); + const raw = res.data?.children ?? []; + return raw.map((item) => { + const c = item.child ?? item; + const [first_name = '', ...rest] = (c.name || '').split(' '); + return { id: c.id, first_name, last_name: rest.join(' '), email: c.email }; + }); +} + +// ---------------------------------------------------------------------- + +function AddChildDialog({ open, onClose, onSuccess }) { + const [code, setCode] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const handleClose = () => { + setCode(''); + setError(''); + onClose(); + }; + + const handleSubmit = async () => { + const trimmed = code.trim().toUpperCase(); + if (!trimmed) { setError('Введите код'); return; } + if (trimmed.length !== 8) { setError('Код должен содержать ровно 8 символов'); return; } + + try { + setLoading(true); + setError(''); + await axios.post('/manage/parents/add_child/', { universal_code: trimmed }); + handleClose(); + onSuccess(); + } catch (e) { + const msg = e?.response?.data?.error + || e?.response?.data?.detail + || e?.message + || 'Ошибка при добавлении'; + setError(msg); + } finally { + setLoading(false); + } + }; + + return ( + + Добавить ребёнка по коду + + + Введите 8-значный код из профиля ребёнка. Код содержит буквы и цифры. + + { + setCode(e.target.value.toUpperCase()); + setError(''); + }} + onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} + inputProps={{ maxLength: 8, style: { letterSpacing: 4, fontWeight: 600, fontSize: 18 } }} + InputProps={{ + startAdornment: ( + + + + ), + }} + error={!!error} + helperText={error || ' '} + /> + + + + + + + ); } // ---------------------------------------------------------------------- @@ -40,10 +131,12 @@ export function ChildrenView() { const [children, setChildren] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [addOpen, setAddOpen] = useState(false); const load = useCallback(async () => { try { setLoading(true); + setError(null); const list = await getChildren(); setChildren(list); } catch (e) { @@ -53,15 +146,22 @@ export function ChildrenView() { } }, []); - useEffect(() => { - load(); - }, [load]); + useEffect(() => { load(); }, [load]); return ( } + onClick={() => setAddOpen(true)} + > + Добавить ребёнка + + } sx={{ mb: 3 }} /> @@ -81,6 +181,14 @@ export function ChildrenView() { Нет привязанных детей + ) : ( @@ -106,18 +214,31 @@ export function ChildrenView() { - + ); })} )} + + setAddOpen(false)} + onSuccess={() => { + load(); + window.dispatchEvent(new Event('child-changed')); + }} + /> ); } diff --git a/front_minimal/src/sections/groups/view/group-detail-view.jsx b/front_minimal/src/sections/groups/view/group-detail-view.jsx new file mode 100644 index 0000000..25bfc60 --- /dev/null +++ b/front_minimal/src/sections/groups/view/group-detail-view.jsx @@ -0,0 +1,983 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useParams } from 'react-router-dom'; + +import { useAuthContext } from 'src/auth/hooks'; + +import Box from '@mui/material/Box'; +import Tab from '@mui/material/Tab'; +import Card from '@mui/material/Card'; +import Chip from '@mui/material/Chip'; +import Grid from '@mui/material/Grid'; +import Tabs from '@mui/material/Tabs'; +import List from '@mui/material/List'; +import Stack from '@mui/material/Stack'; +import Alert from '@mui/material/Alert'; +import Table from '@mui/material/Table'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import ListItem from '@mui/material/ListItem'; +import TextField from '@mui/material/TextField'; +import TableRow from '@mui/material/TableRow'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import CardContent from '@mui/material/CardContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import ListItemText from '@mui/material/ListItemText'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import TableContainer from '@mui/material/TableContainer'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { resolveMediaUrl } from 'src/utils/axios'; +import { getStudents } from 'src/utils/students-api'; +import { getHomework, getHomeworkSubmissions } from 'src/utils/homework-api'; +import { getMyMaterials, shareMaterial, getMaterialTypeIcon } from 'src/utils/materials-api'; +import { getOrCreateGroupBoard } from 'src/utils/board-api'; +import { + getGroupById, + updateGroup, + getGroupLessons, + addStudentToGroup, + removeStudentFromGroup, +} from 'src/utils/groups-api'; + +import { DashboardContent } from 'src/layouts/dashboard'; +import { Iconify } from 'src/components/iconify'; +import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; + +// ---------------------------------------------------------------------- + +function fDateTime(str) { + if (!str) return '—'; + return new Date(str).toLocaleString('ru-RU', { + day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', + }); +} + +function fDate(str) { + if (!str) return '—'; + return new Date(str).toLocaleDateString('ru-RU', { + day: 'numeric', month: 'short', year: 'numeric', + }); +} + +const STATUS_MAP = { + scheduled: { label: 'Запланировано', color: 'default' }, + completed: { label: 'Завершено', color: 'success' }, + cancelled: { label: 'Отменено', color: 'error' }, + in_progress: { label: 'Идёт', color: 'warning' }, +}; + +function studentName(s) { + if (!s) return '—'; + const u = s.user || s; + return `${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email || `ID: ${s.id}`; +} + +function studentAvatar(s) { + const u = s?.user || s; + return u?.avatar ? resolveMediaUrl(u.avatar) : null; +} + +function studentInitials(s) { + return studentName(s).charAt(0).toUpperCase(); +} + +// ---------------------------------------------------------------------- +// Stat card + +function StatCard({ icon, label, value, color = 'primary' }) { + return ( + + + + + + + {value ?? '—'} + {label} + + + + ); +} + +// ---------------------------------------------------------------------- +// Students tab + +function StudentsTab({ group, onRefresh, allStudents, isMentor }) { + const [searchQuery, setSearchQuery] = useState(''); + const [addOpen, setAddOpen] = useState(false); + const [loading, setLoading] = useState(false); + + const memberIds = new Set((group.students || []).map((s) => s.id)); + + const notMembers = allStudents.filter((s) => !memberIds.has(s.id)).filter((s) => { + if (!searchQuery) return true; + return studentName(s).toLowerCase().includes(searchQuery.toLowerCase()); + }); + + const handleRemove = async (studentId) => { + setLoading(true); + try { + await removeStudentFromGroup(group.id, studentId); + await onRefresh(); + } catch (e) { console.error(e); } + finally { setLoading(false); } + }; + + const handleAdd = async (studentId) => { + setLoading(true); + try { + await addStudentToGroup(group.id, studentId); + await onRefresh(); + } catch (e) { console.error(e); } + finally { setLoading(false); } + }; + + return ( + + + + Участники ({group.students?.length ?? 0}) + + {isMentor && ( + + )} + + + {group.students?.length === 0 && ( + + + Нет участников + + )} + + + {(group.students || []).map((s) => ( + handleRemove(s.id)}> + + + ) : undefined} + > + + + {studentInitials(s)} + + + + + ))} + + + {/* Диалог добавления */} + setAddOpen(false)} maxWidth="xs" fullWidth> + Добавить участника + + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: , + }} + /> + + + {notMembers.length === 0 && ( + + )} + {notMembers.map((s) => ( + handleAdd(s.id)}> + + + } + > + + + {studentInitials(s)} + + + + + ))} + + + + + + + + ); +} + +// ---------------------------------------------------------------------- +// Lessons tab + +function LessonsTab({ groupId }) { + const router = useRouter(); + const [lessons, setLessons] = useState([]); + const [loading, setLoading] = useState(true); + const [statusFilter, setStatusFilter] = useState('all'); + + useEffect(() => { + setLoading(true); + getGroupLessons(groupId) + .then(setLessons) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [groupId]); + + if (loading) return ; + + const filtered = statusFilter === 'all' ? lessons : lessons.filter((l) => l.status === statusFilter); + + return ( + + + {['all', 'scheduled', 'completed', 'cancelled'].map((s) => ( + setStatusFilter(s)} + /> + ))} + + + {filtered.length === 0 ? ( + + + Нет занятий + + ) : ( + + + + + Дата и время + Предмет + Статус + Стоимость + + + + + {filtered.map((lesson) => { + const cfg = STATUS_MAP[lesson.status] || { label: lesson.status, color: 'default' }; + return ( + + + {fDateTime(lesson.start_time)} + + + {lesson.subject_name || '—'} + + + + + + {lesson.price ? `${lesson.price} ₽` : '—'} + + + router.push(paths.dashboard.lesson(lesson.id))} + > + + + + + ); + })} + +
+
+ )} +
+ ); +} + +// ---------------------------------------------------------------------- +// Homework tab + +function HomeworkTab({ groupId }) { + const router = useRouter(); + const [homeworks, setHomeworks] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + // Загружаем уроки группы, затем ДЗ по каждому уроку + getGroupLessons(groupId) + .then(async (lessons) => { + const lessonIds = lessons.map((l) => l.id); + if (lessonIds.length === 0) { setHomeworks([]); return; } + // Загружаем ДЗ по каждому уроку параллельно (берём первые 20 уроков) + const results = await Promise.all( + lessonIds.slice(0, 20).map((lid) => + getHomework({ lesson_id: lid, page_size: 10 }).catch(() => ({ results: [] })) + ) + ); + const all = results.flatMap((r) => r.results ?? []); + // Дедупликация по id + const seen = new Set(); + setHomeworks(all.filter((hw) => { if (seen.has(hw.id)) return false; seen.add(hw.id); return true; })); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [groupId]); + + if (loading) return ; + + if (homeworks.length === 0) { + return ( + + + Нет домашних заданий + + ); + } + + return ( + + {homeworks.map((hw) => { + const submitted = hw.total_submissions ?? 0; + const checked = hw.checked_submissions ?? 0; + const assigned = Array.isArray(hw.assigned_to) ? hw.assigned_to.length : 0; + return ( + router.push(paths.dashboard.homeworkDetail(hw.id))} + > + + + {hw.title} + {hw.deadline && ( + + Дедлайн: {fDate(hw.deadline)} + + )} + + + + {assigned > 0 && ( + + Решений: {submitted}/{assigned} + {checked > 0 && ` • Проверено: ${checked}`} + + )} + + + + ); + })} + + ); +} + +// ---------------------------------------------------------------------- +// Materials tab + +function MaterialsTab({ group }) { + const [materials, setMaterials] = useState([]); + const [loading, setLoading] = useState(true); + const [sharing, setSharing] = useState(null); + const [sharedIds, setSharedIds] = useState(new Set()); + + useEffect(() => { + getMyMaterials() + .then(setMaterials) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + const handleShare = async (materialId) => { + const studentUserIds = (group.students || []) + .map((s) => s.user?.id ?? s.id) + .filter(Boolean); + if (studentUserIds.length === 0) return; + setSharing(materialId); + try { + await shareMaterial(materialId, studentUserIds); + setSharedIds((prev) => new Set([...prev, materialId])); + } catch (e) { + console.error(e); + } finally { + setSharing(null); + } + }; + + if (loading) return ; + + if (materials.length === 0) { + return ( + + + У вас нет материалов + + ); + } + + return ( + + + Поделитесь материалами со всеми участниками группы ({group.students?.length ?? 0} чел.) + + {materials.map((m) => { + const isShared = sharedIds.has(m.id); + return ( + + + + + + + {m.title} + {m.description && ( + {m.description} + )} + + + + + ); + })} + + ); +} + +// ---------------------------------------------------------------------- +// Progress tab — матрица «ученик × задание» + +const SUBMISSION_STATUS = { + submitted: { label: 'Сдано', color: '#2065d1', bg: '#d1e9fc' }, + graded: { label: 'Оценено', color: '#118d57', bg: '#d3f4e4' }, + returned_revision: { label: 'На доработке', color: '#b76e00', bg: '#fff5cc' }, + pending: { label: 'Не сдано', color: '#919eab', bg: '#f4f6f8' }, +}; + +function SubmissionCell({ status, score, maxScore }) { + const cfg = SUBMISSION_STATUS[status] ?? SUBMISSION_STATUS.pending; + return ( + + {status === 'graded' && score != null ? `${score}/${maxScore}` : cfg.label} + + ); +} + +function ProgressTab({ groupId, group }) { + const [matrix, setMatrix] = useState(null); // { students, homeworks, cells } + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + setLoading(true); + setError(''); + + getGroupLessons(groupId) + .then(async (lessons) => { + if (lessons.length === 0) { setMatrix({ students: [], homeworks: [], cells: {} }); return; } + + // Загружаем ДЗ по первым 15 урокам + const hwResults = await Promise.all( + lessons.slice(0, 15).map((l) => + getHomework({ lesson_id: l.id, page_size: 50 }).catch(() => ({ results: [] })) + ) + ); + const allHw = hwResults.flatMap((r) => r.results ?? []); + const seen = new Set(); + const homeworks = allHw.filter((hw) => { + if (seen.has(hw.id)) return false; + seen.add(hw.id); + return hw.status === 'published'; + }).slice(0, 12); // max 12 колонок + + if (homeworks.length === 0) { setMatrix({ students: [], homeworks: [], cells: {} }); return; } + + // Загружаем сдачи по каждому ДЗ + const submissionsAll = await Promise.all( + homeworks.map((hw) => + getHomeworkSubmissions(hw.id).catch(() => []) + ) + ); + + // cells[studentId][hwId] = { status, score } + const cells = {}; + submissionsAll.forEach((subs, idx) => { + const hw = homeworks[idx]; + subs.forEach((sub) => { + const sid = sub.student ?? sub.student_id ?? sub.user?.id; + if (!sid) return; + if (!cells[sid]) cells[sid] = {}; + cells[sid][hw.id] = { + status: sub.is_graded ? 'graded' : sub.status ?? 'submitted', + score: sub.score, + maxScore: hw.max_score ?? 5, + }; + }); + }); + + setMatrix({ students: group.students ?? [], homeworks, cells }); + }) + .catch((e) => { console.error(e); setError('Не удалось загрузить прогресс'); }) + .finally(() => setLoading(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [groupId]); + + if (loading) return ; + if (error) return {error}; + if (!matrix || matrix.homeworks.length === 0) { + return ( + + + Нет опубликованных домашних заданий + + ); + } + + const { students, homeworks, cells } = matrix; + + return ( + + {/* Легенда */} + + {Object.entries(SUBMISSION_STATUS).map(([key, cfg]) => ( + + {cfg.label} + + ))} + + + + + + + Ученик + {homeworks.map((hw) => ( + + + {hw.title} + + {hw.deadline && ( + + {fDate(hw.deadline)} + + )} + + ))} + Сдано + + + + {students.map((s) => { + const sid = s.user?.id ?? s.id; + const studentCells = cells[sid] ?? {}; + const submittedCount = Object.values(studentCells).filter( + (c) => ['submitted', 'graded', 'returned_revision'].includes(c.status) + ).length; + + return ( + + + + + {studentInitials(s)} + + + {studentName(s)} + + + + {homeworks.map((hw) => { + const cell = studentCells[hw.id]; + return ( + + + + ); + })} + + + {submittedCount}/{homeworks.length} + + + + ); + })} + +
+
+
+ ); +} + +// ---------------------------------------------------------------------- +// Edit dialog + +function EditGroupDialog({ open, onClose, group, onSaved }) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (open && group) { setName(group.name || ''); setDescription(group.description || ''); setError(''); } + }, [open, group]); + + const handleSave = async () => { + if (!name.trim()) { setError('Введите название'); return; } + setLoading(true); + setError(''); + try { + await updateGroup(group.id, { name: name.trim(), description: description.trim() }); + onSaved(); + onClose(); + } catch (e) { + setError(e?.response?.data?.error?.message || 'Ошибка сохранения'); + } finally { setLoading(false); } + }; + + return ( + + Редактировать группу + + + {error && {error}} + setName(e.target.value)} fullWidth autoFocus /> + setDescription(e.target.value)} fullWidth multiline rows={2} /> + + + + + + + + ); +} + +// ---------------------------------------------------------------------- +// Main view + +export function GroupDetailView() { + const { id } = useParams(); + const router = useRouter(); + const { user } = useAuthContext(); + const isMentor = user?.role === 'mentor'; + + const [group, setGroup] = useState(null); + const [allStudents, setAllStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState('students'); + const [editOpen, setEditOpen] = useState(false); + const [boardLoading, setBoardLoading] = useState(false); + + const loadGroup = useCallback(async () => { + try { + const data = await getGroupById(id); + setGroup(data); + } catch (e) { + console.error(e); + } + }, [id]); + + useEffect(() => { + setLoading(true); + Promise.all([ + getGroupById(id), + isMentor ? getStudents({ page_size: 1000 }) : Promise.resolve([]), + ]) + .then(([groupData, studentsData]) => { + setGroup(groupData); + setAllStudents(studentsData?.results ?? studentsData ?? []); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [id]); + + const handleOpenBoard = async () => { + setBoardLoading(true); + try { + const board = await getOrCreateGroupBoard(id); + router.push(`${paths.dashboard.board}?id=${board.board_id}`); + } catch (e) { + console.error(e); + } finally { + setBoardLoading(false); + } + }; + + if (loading) { + return ( + + + + ); + } + + if (!group) { + return ( + + Группа не найдена + + ); + } + + const studentCount = group.students?.length ?? group.students_count ?? 0; + const scheduledLessons = group.scheduled_lessons ?? 0; + const completedLessons = group.completed_lessons ?? 0; + const totalLessons = scheduledLessons + completedLessons; + + return ( + + + + {/* Шапка */} + + + + + + + + {group.name} + {group.description && ( + + {group.description} + + )} + + Создана {fDate(group.created_at)} + + + + + {isMentor && ( + + )} + + + + + + {/* Статистика */} + + + + + + + + + + + + + + + + {/* Аватары участников (превью) */} + {group.students && group.students.length > 0 && ( + + Участники группы + + {group.students.map((s) => ( + + + {studentInitials(s)} + + + {(s.user?.first_name || studentName(s)).split(' ')[0]} + + + ))} + + + )} + + {/* Табы */} + + setTab(v)} + sx={{ px: 2, borderBottom: '1px solid', borderColor: 'divider' }} + > + } + iconPosition="start" + /> + } + iconPosition="start" + /> + } + iconPosition="start" + /> + {isMentor && ( + } + iconPosition="start" + /> + )} + } + iconPosition="start" + /> + + + + {tab === 'students' && ( + + )} + {tab === 'lessons' && ( + + )} + {tab === 'homework' && ( + + )} + {tab === 'materials' && isMentor && ( + + )} + {tab === 'progress' && ( + + )} + + + + setEditOpen(false)} + group={group} + onSaved={loadGroup} + /> + + ); +} diff --git a/front_minimal/src/sections/groups/view/groups-view.jsx b/front_minimal/src/sections/groups/view/groups-view.jsx new file mode 100644 index 0000000..554551c --- /dev/null +++ b/front_minimal/src/sections/groups/view/groups-view.jsx @@ -0,0 +1,458 @@ +import { useState, useEffect, useCallback } from 'react'; + +import { useAuthContext } from 'src/auth/hooks'; + +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Chip from '@mui/material/Chip'; +import Grid from '@mui/material/Grid'; +import List from '@mui/material/List'; +import Stack from '@mui/material/Stack'; +import Alert from '@mui/material/Alert'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import ListItem from '@mui/material/ListItem'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import CardHeader from '@mui/material/CardHeader'; +import CardContent from '@mui/material/CardContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import ListItemText from '@mui/material/ListItemText'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { resolveMediaUrl } from 'src/utils/axios'; +import { getStudents } from 'src/utils/students-api'; +import { + getGroups, + createGroup, + updateGroup, + deleteGroup, + addStudentToGroup, + removeStudentFromGroup, +} from 'src/utils/groups-api'; +import { DashboardContent } from 'src/layouts/dashboard'; +import { Iconify } from 'src/components/iconify'; + +// ---------------------------------------------------------------------- + +function StudentAvatar({ student, size = 36 }) { + const avatarUrl = student?.user?.avatar ? resolveMediaUrl(student.user.avatar) : null; + const name = student?.user?.full_name || + (student?.user ? `${student.user.first_name} ${student.user.last_name}`.trim() : '') || + `ID: ${student?.id}`; + return ( + + {name.charAt(0).toUpperCase()} + + ); +} + +function GroupCard({ group, onEdit, onDelete, onManageStudents }) { + const router = useRouter(); + const studentCount = group.students_count ?? group.students?.length ?? 0; + const scheduledLessons = group.scheduled_lessons ?? 0; + const completedLessons = group.completed_lessons ?? 0; + + return ( + + router.push(paths.dashboard.groupDetail(group.id))} + > + {group.name} + + } + subheader={group.description || 'Без описания'} + action={ + + router.push(paths.dashboard.groupDetail(group.id))} title="Открыть"> + + + { e.stopPropagation(); onManageStudents(group); }}> + + + { e.stopPropagation(); onEdit(group); }}> + + + { e.stopPropagation(); onDelete(group); }}> + + + + } + /> + + + } + label={`${studentCount} ${studentCount === 1 ? 'ученик' : studentCount < 5 ? 'ученика' : 'учеников'}`} + /> + {scheduledLessons > 0 && ( + + )} + {completedLessons > 0 && ( + + )} + + + {group.students && group.students.length > 0 && ( + + {group.students.slice(0, 6).map((s) => ( + + ))} + {group.students.length > 6 && ( + + +{group.students.length - 6} + + )} + + )} + + + ); +} + +// ---------------------------------------------------------------------- + +function GroupFormDialog({ open, onClose, onSave, initial }) { + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + setName(initial?.name || ''); + setDescription(initial?.description || ''); + setError(''); + } + }, [open, initial]); + + const handleSave = async () => { + if (!name.trim()) { setError('Введите название группы'); return; } + setLoading(true); + setError(''); + try { + await onSave({ name: name.trim(), description: description.trim() }); + onClose(); + } catch (e) { + const msg = e?.response?.data?.error?.message || e?.response?.data?.detail || 'Ошибка сохранения'; + setError(msg); + } finally { + setLoading(false); + } + }; + + return ( + + {initial?.id ? 'Редактировать группу' : 'Создать группу'} + + + {error && {error}} + setName(e.target.value)} + fullWidth + autoFocus + /> + setDescription(e.target.value)} + fullWidth + multiline + rows={2} + /> + + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +function ManageStudentsDialog({ open, onClose, group, allStudents, onRefresh }) { + const [loading, setLoading] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + + if (!group) return null; + + const memberIds = new Set((group.students || []).map((s) => s.id)); + + const filtered = allStudents.filter((s) => { + if (!searchQuery) return true; + const name = s.user?.full_name || + `${s.user?.first_name || ''} ${s.user?.last_name || ''}`.trim() || + s.user?.email || ''; + return name.toLowerCase().includes(searchQuery.toLowerCase()); + }); + + const handleToggle = async (studentId) => { + setLoading(true); + try { + if (memberIds.has(studentId)) { + await removeStudentFromGroup(group.id, studentId); + } else { + await addStudentToGroup(group.id, studentId); + } + await onRefresh(); + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + return ( + + Ученики — {group.name} + + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: , + }} + /> + + + {filtered.length === 0 && ( + + + + )} + {filtered.map((student) => { + const isMember = memberIds.has(student.id); + const avatarUrl = student.user?.avatar ? resolveMediaUrl(student.user.avatar) : null; + const name = student.user?.full_name || + `${student.user?.first_name || ''} ${student.user?.last_name || ''}`.trim() || + student.user?.email || `ID: ${student.id}`; + + return ( + handleToggle(student.id)} + > + + + } + > + + + {name.charAt(0).toUpperCase()} + + + + + ); + })} + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +export function GroupsView() { + const { user } = useAuthContext(); + const isMentor = user?.role === 'mentor'; + + const [groups, setGroups] = useState([]); + const [allStudents, setAllStudents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + const [formOpen, setFormOpen] = useState(false); + const [editingGroup, setEditingGroup] = useState(null); + + const [manageOpen, setManageOpen] = useState(false); + const [managingGroup, setManagingGroup] = useState(null); + + const [deleteDialogGroup, setDeleteDialogGroup] = useState(null); + const [deleteLoading, setDeleteLoading] = useState(false); + + const fetchGroups = useCallback(async () => { + try { + const data = await getGroups(); + setGroups(data); + } catch (e) { + setError('Не удалось загрузить группы'); + } + }, []); + + useEffect(() => { + setLoading(true); + Promise.all([ + getGroups(), + isMentor ? getStudents({ page_size: 1000 }) : Promise.resolve([]), + ]) + .then(([groupsData, studentsData]) => { + setGroups(groupsData); + setAllStudents(studentsData?.results ?? studentsData ?? []); + }) + .catch(() => setError('Не удалось загрузить данные')) + .finally(() => setLoading(false)); + }, []); + + const handleOpenCreate = () => { setEditingGroup(null); setFormOpen(true); }; + const handleOpenEdit = (group) => { setEditingGroup(group); setFormOpen(true); }; + + const handleSaveGroup = async (data) => { + if (editingGroup?.id) { + await updateGroup(editingGroup.id, data); + } else { + await createGroup(data); + } + await fetchGroups(); + }; + + const handleDelete = async () => { + if (!deleteDialogGroup) return; + setDeleteLoading(true); + try { + await deleteGroup(deleteDialogGroup.id); + await fetchGroups(); + } catch (e) { + console.error(e); + } finally { + setDeleteLoading(false); + setDeleteDialogGroup(null); + } + }; + + const handleManageStudents = (group) => { + setManagingGroup(group); + setManageOpen(true); + }; + + const handleRefreshManaging = async () => { + await fetchGroups(); + // Update managingGroup with fresh data + const fresh = await getGroups(); + setGroups(fresh); + const updated = fresh.find((g) => g.id === managingGroup?.id); + if (updated) setManagingGroup(updated); + }; + + return ( + + + Группы + + + + {error && {error}} + + {loading ? ( + + + + ) : groups.length === 0 ? ( + + + + + Нет групп + + Создайте первую учебную группу для организации групповых занятий + + + + + + ) : ( + + {groups.map((group) => ( + + + + ))} + + )} + + setFormOpen(false)} + onSave={handleSaveGroup} + initial={editingGroup} + /> + + { setManageOpen(false); setManagingGroup(null); }} + group={managingGroup} + allStudents={allStudents} + onRefresh={handleRefreshManaging} + /> + + {/* Delete confirm */} + setDeleteDialogGroup(null)} maxWidth="xs" fullWidth> + Удалить группу? + + + Группа {deleteDialogGroup?.name} будет удалена. Это действие нельзя отменить. + + + + + + + + + ); +} diff --git a/front_minimal/src/sections/groups/view/index.js b/front_minimal/src/sections/groups/view/index.js new file mode 100644 index 0000000..8bfb752 --- /dev/null +++ b/front_minimal/src/sections/groups/view/index.js @@ -0,0 +1,2 @@ +export { GroupsView } from './groups-view'; +export { GroupDetailView } from './group-detail-view'; diff --git a/front_minimal/src/sections/homework/view/homework-detail-view.jsx b/front_minimal/src/sections/homework/view/homework-detail-view.jsx new file mode 100644 index 0000000..7ec7666 --- /dev/null +++ b/front_minimal/src/sections/homework/view/homework-detail-view.jsx @@ -0,0 +1,1028 @@ +'use client'; + +import { useRef, useState, useEffect, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Tab from '@mui/material/Tab'; +import Card from '@mui/material/Card'; +import Chip from '@mui/material/Chip'; +import Tabs from '@mui/material/Tabs'; +import Alert from '@mui/material/Alert'; +import Stack from '@mui/material/Stack'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import Rating from '@mui/material/Rating'; +import Divider from '@mui/material/Divider'; +import Tooltip from '@mui/material/Tooltip'; +import Skeleton from '@mui/material/Skeleton'; +import TextField from '@mui/material/TextField'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import LoadingButton from '@mui/lab/LoadingButton'; +import CardContent from '@mui/material/CardContent'; +import LinearProgress from '@mui/material/LinearProgress'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { resolveMediaUrl } from 'src/utils/axios'; +import { + submitHomework, + getHomeworkById, + getMySubmission, + gradeSubmission, + deleteSubmission, + getHomeworkStatus, + uploadHomeworkFile, + deleteHomeworkFile, + checkSubmissionWithAi, + getHomeworkSubmissions, + returnSubmissionForRevision, +} from 'src/utils/homework-api'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { Iconify } from 'src/components/iconify'; + +import { useAuthContext } from 'src/auth/hooks'; + +// ---------------------------------------------------------------------- + +const resolveUrl = (href) => resolveMediaUrl(href); + +function formatDateTime(s) { + if (!s) return '—'; + const d = new Date(s); + return Number.isNaN(d.getTime()) + ? '—' + : d.toLocaleString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit' }); +} + +function formatDate(s) { + if (!s) return '—'; + const d = new Date(s); + return Number.isNaN(d.getTime()) + ? '—' + : d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' }); +} + +function getFileExt(name = '') { + return name.split('.').pop().toLowerCase(); +} + +function getFileIcon(name = '') { + const ext = getFileExt(name); + if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'solar:gallery-bold'; + if (['mp4', 'webm', 'mov', 'avi', 'mkv'].includes(ext)) return 'solar:videocamera-bold'; + if (ext === 'pdf') return 'solar:file-text-bold'; + if (['doc', 'docx'].includes(ext)) return 'solar:document-bold'; + if (['xls', 'xlsx'].includes(ext)) return 'solar:graph-bold'; + if (['zip', 'rar', '7z'].includes(ext)) return 'solar:zip-file-bold'; + return 'solar:file-bold'; +} + +function isImage(name = '') { + return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'].includes(getFileExt(name)); +} + +function formatSize(bytes) { + if (!bytes) return ''; + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} МБ`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} КБ`; + return `${bytes} Б`; +} + +// ---------------------------------------------------------------------- + +const HW_STATUS_CONFIG = { + published: { label: 'Опубликовано', color: 'success' }, + draft: { label: 'Черновик', color: 'default' }, + archived: { label: 'Архив', color: 'default' }, +}; + +const SUB_STATUS_CONFIG = { + pending: { label: 'Ожидает сдачи', color: 'default' }, + submitted: { label: 'На проверке', color: 'info' }, + returned: { label: 'На доработке', color: 'warning' }, + reviewed: { label: 'Проверено', color: 'success' }, +}; + +// ---------------------------------------------------------------------- +// File preview + +function FileCard({ file, onDelete, deleting }) { + const name = file.file_name || file.filename || file.name || (file.file ? String(file.file).split('/').pop() : 'Файл'); + const url = resolveUrl(file.file_url || file.file || file.url || ''); + const img = isImage(name); + + return ( + + {img && url ? ( + + + + ) : ( + + + + )} + + + {!img && ( + + )} + + {name} + {file.file_size && ( + {formatSize(file.file_size)} + )} + + {url && ( + + + + + + )} + {onDelete && ( + + + + + + )} + + + ); +} + +function FileGrid({ files, onDelete }) { + const [deletingId, setDeletingId] = useState(null); + + if (!files?.length) return null; + + const handleDelete = async (file) => { + setDeletingId(file.id); + try { await onDelete(file.id); } finally { setDeletingId(null); } + }; + + return ( + + {files.map((f) => ( + handleDelete(f) : null} + deleting={deletingId === f.id} + /> + ))} + + ); +} + +// ---------------------------------------------------------------------- +// Submission status badge for client + +function SubmissionBadge({ submission }) { + if (!submission) return null; + const st = SUB_STATUS_CONFIG[submission.status] || SUB_STATUS_CONFIG.submitted; + return ( + + + + Сдано: {formatDateTime(submission.submitted_at)} + + + ); +} + +// ---------------------------------------------------------------------- +// Client — submit section + +function ClientSubmitSection({ homework, childId, onReload }) { + const fileInputRef = useRef(null); + const [submission, setSubmission] = useState(undefined); + const [content, setContent] = useState(''); + const [files, setFiles] = useState([]); + const [uploading, setUploading] = useState(false); + const [progress, setProgress] = useState(0); + const [deleting, setDeleting] = useState(false); + const [error, setError] = useState(null); + + const load = useCallback(() => { + getMySubmission(homework.id, childId ? { child_id: childId } : undefined) + .then(setSubmission) + .catch(() => setSubmission(null)); + }, [homework.id, childId]); + + useEffect(() => { load(); }, [load]); + + const handleFileAdd = (e) => { + const list = Array.from(e.target.files || []); + setFiles((prev) => [...prev, ...list].slice(0, 10)); + e.target.value = ''; + }; + + const handleRemoveLocalFile = (idx) => setFiles((prev) => prev.filter((_, i) => i !== idx)); + + const handleSubmit = async () => { + if (!content.trim() && files.length === 0) { + setError('Добавьте текст или прикрепите файлы'); + return; + } + setUploading(true); + setProgress(0); + setError(null); + try { + await submitHomework(homework.id, { content: content.trim(), files }, (p) => setProgress(p)); + setContent(''); + setFiles([]); + load(); + onReload(); + } catch (e) { + setError(e?.response?.data?.detail || e?.message || 'Не удалось отправить'); + } finally { + setUploading(false); + setProgress(0); + } + }; + + const handleDeleteSub = async () => { + if (!submission) return; + setDeleting(true); + try { + await deleteSubmission(submission.id); + setSubmission(null); + onReload(); + } catch (e) { + setError(e?.response?.data?.detail || e?.message || 'Ошибка'); + } finally { + setDeleting(false); + } + }; + + if (submission === undefined) { + return ; + } + + // Already submitted — show result + if (submission) { + const isChecked = submission.status === 'checked'; + const isReturned = submission.status === 'returned'; + const subFiles = (submission.files ?? []).filter((f) => f.file_type === 'submission'); + return ( + + + + {submission.content && ( + + {submission.content} + + )} + + {(submission.attachment || subFiles.length > 0) && ( + + + Прикреплённые файлы + + + + )} + + {submission.score != null && ( + + + + + Оценка + + + + {submission.score} / {homework.max_score || 5} + + + + + )} + + {submission.feedback && ( + + Комментарий ментора + + {submission.feedback_html ? ( + + ) : ( + {submission.feedback} + )} + + + )} + + {error && {error}} + + {!isChecked && !childId && ( + + Удалить и переотправить + + )} + + ); + } + + // Not submitted — show form + if (homework.status !== 'published' || childId) { + return ( + + {childId ? 'Просмотр: сдача от лица ребёнка недоступна' : 'Задание ещё не опубликовано'} + + ); + } + + return ( + + {error && setError(null)}>{error}} + + setContent(e.target.value)} + multiline + rows={5} + fullWidth + /> + + + + {files.length > 0 && ( + + {files.map((f, i) => ( + + + {f.name} + {formatSize(f.size)} + handleRemoveLocalFile(i)}> + + + + ))} + + )} + + {uploading && } + + + } + > + Отправить + + + + + ); +} + +// ---------------------------------------------------------------------- +// Mentor — single submission card + +function MentorSubmissionCard({ submission, homework, onReload }) { + const [score, setScore] = useState(submission.score != null ? submission.score : 0); + const [feedback, setFeedback] = useState(submission.feedback || ''); + const [returnMsg, setReturnMsg] = useState(''); + const [showReturn, setShowReturn] = useState(false); + const [grading, setGrading] = useState(false); + const [returning, setReturning] = useState(false); + const [aiChecking, setAiChecking] = useState(false); + const [error, setError] = useState(null); + + const isChecked = submission.status === 'checked'; + const isReturned = submission.status === 'returned'; + const subFiles = (submission.files ?? []).filter((f) => f.file_type === 'submission'); + const { student } = submission; + + const handleGrade = async () => { + setGrading(true); + setError(null); + try { + await gradeSubmission(submission.id, { score: Number(score), feedback: feedback.trim() }); + onReload(); + } catch (e) { + setError(e?.response?.data?.detail || e?.message || 'Ошибка'); + } finally { + setGrading(false); + } + }; + + const handleReturn = async () => { + if (!returnMsg.trim()) { setError('Укажите причину возврата'); return; } + setReturning(true); + setError(null); + try { + await returnSubmissionForRevision(submission.id, returnMsg.trim()); + onReload(); + } catch (e) { + setError(e?.response?.data?.detail || e?.message || 'Ошибка'); + } finally { + setReturning(false); + } + }; + + const handleAiCheck = async () => { + setAiChecking(true); + setError(null); + try { + const result = await checkSubmissionWithAi(submission.id); + if (result.ai_score != null) setScore(result.ai_score); + if (result.ai_feedback) setFeedback(result.ai_feedback); + onReload(); + } catch (e) { + setError(e?.response?.data?.detail || e?.message || 'Ошибка AI-проверки'); + } finally { + setAiChecking(false); + } + }; + + return ( + + + {/* Student header */} + + + + {student?.first_name?.[0]}{student?.last_name?.[0]} + + + + {student?.first_name} {student?.last_name} + + + {formatDateTime(submission.submitted_at)} + + + + + + + {/* Content */} + {submission.content && ( + + {submission.content} + + )} + + {(submission.attachment || subFiles.length > 0) && ( + + Файлы решения + + + )} + + {/* AI draft */} + {submission.ai_score != null && !isChecked && ( + + + ИИ-черновик: {submission.ai_score}/5 + + {submission.ai_feedback && ( + {submission.ai_feedback} + )} + + )} + + {/* Already graded info */} + {isChecked && submission.score != null && ( + + + {submission.score} / {homework.max_score || 5} + + )} + + {error && {error}} + + {/* Grade form */} + {!isChecked && !showReturn && ( + + + Оценка: + setScore(v ?? 0)} + /> + + setFeedback(e.target.value)} + size="small" + fullWidth + /> + + + Выставить оценку + + + } + > + ИИ-проверка + + + + )} + + {/* Return form */} + {showReturn && ( + + setReturnMsg(e.target.value)} + size="small" + fullWidth + /> + + + Вернуть + + + + + )} + + + ); +} + +// ---------------------------------------------------------------------- +// Skeleton + +function HwSkeleton() { + return ( + + + + + + + + + + + + + + + + ); +} + +// ---------------------------------------------------------------------- +// Main + +export function HomeworkDetailView({ id }) { + const router = useRouter(); + const { user } = useAuthContext(); + const isMentor = user?.role === 'mentor'; + const isParent = user?.role === 'parent'; + + const [hw, setHw] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [submissions, setSubmissions] = useState([]); + const [subsLoading, setSubsLoading] = useState(false); + const [tab, setTab] = useState(0); + + const [hwFiles, setHwFiles] = useState([]); + const fileInputRef = useRef(null); + const [uploading, setUploading] = useState(false); + + const loadHw = useCallback(() => { + if (!id || id === 'undefined') { + setError('Некорректный ID задания'); + setLoading(false); + return; + } + getHomeworkById(id) + .then((data) => { + const fetched = data?.data ?? data; + if (!fetched?.id) { setError('Задание не найдено'); return; } + setHw(fetched); + setHwFiles((fetched.files ?? []).filter((f) => f.file_type === 'assignment')); + }) + .catch((e) => { + const st = e?.response?.status; + setError(st === 404 || st === 403 ? 'Задание не найдено или нет доступа' : e?.response?.data?.detail || e?.message || 'Ошибка загрузки'); + }) + .finally(() => setLoading(false)); + }, [id]); + + const loadSubmissions = useCallback(() => { + if (!hw || !isMentor) return; + setSubsLoading(true); + getHomeworkSubmissions(hw.id) + .then(setSubmissions) + .catch(() => setSubmissions([])) + .finally(() => setSubsLoading(false)); + }, [hw, isMentor]); + + useEffect(() => { loadHw(); }, [loadHw]); + useEffect(() => { if (hw && isMentor) loadSubmissions(); }, [hw, isMentor, loadSubmissions]); + + const handleUploadFile = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + try { + const created = await uploadHomeworkFile(hw.id, file); + setHwFiles((prev) => [...prev, created]); + } catch { /* ignore */ } finally { + setUploading(false); + e.target.value = ''; + } + }; + + const handleDeleteFile = async (fileId) => { + await deleteHomeworkFile(fileId); + setHwFiles((prev) => prev.filter((f) => f.id !== fileId)); + }; + + if (loading) return ; + + if (error || !hw) { + return ( + + {error || 'Задание не найдено'} + + + ); + } + + const hwStatus = getHomeworkStatus(hw); + const hwStatusCfg = HW_STATUS_CONFIG[hw.status] || { label: hw.status, color: 'default' }; + const clientStatusCfg = SUB_STATUS_CONFIG[hwStatus] || SUB_STATUS_CONFIG.pending; + + const isOverdue = hw.is_overdue; + + return ( + + + {/* Breadcrumb */} + + router.push(paths.dashboard.homework)}> + + + router.push(paths.dashboard.homework)} + > + Домашние задания + + + + {/* Header */} + + + + {hw.title} + {isMentor + ? + : + } + + {hw.deadline && ( + + Дедлайн: {formatDateTime(hw.deadline)} + {isOverdue && ' · Просрочено'} + + )} + + + + {/* Main grid */} + + + {/* Left column */} + + + {/* Description */} + {hw.description && ( + + + Задание + + {hw.description} + + + + )} + + {/* Fill-later warning */} + {isMentor && hw.fill_later && ( + Задание ожидает заполнения + )} + + {/* Assignment files */} + {(hwFiles.length > 0 || (isMentor && hw.status !== 'archived')) && ( + + + + + Файлы задания + {hwFiles.length > 0 && } + + {isMentor && hw.status !== 'archived' && ( + <> + + } + onClick={() => fileInputRef.current?.click()} + > + Загрузить + + + )} + + + {hwFiles.length > 0 ? ( + + ) : ( + Файлы не прикреплены + )} + + {hw.attachment_url && ( + + )} + + + )} + + {/* Client: submission */} + {!isMentor && hw.status === 'published' && ( + + + + {isParent ? 'Решение ученика' : 'Ваше решение'} + + + + + )} + + {/* Mentor: submissions */} + {isMentor && ( + + setTab(v)} + sx={{ mb: 2, borderBottom: '1px solid', borderColor: 'divider' }} + > + + + + + {tab === 1 && ( + + {subsLoading ? ( + <> + + + + ) : submissions.length === 0 ? ( + Решений пока нет + ) : ( + submissions.map((sub) => ( + { loadSubmissions(); loadHw(); }} + /> + )) + )} + + )} + + )} + + + {/* Right sidebar */} + + + + Информация + + + + + Дедлайн + + {formatDate(hw.deadline)} + + + + + + + + + + Макс. балл + {hw.max_score || 5} + + + + {hw.lesson && ( + <> + + + + + Урок + router.push(paths.dashboard.lesson(hw.lesson))} + > + Перейти к уроку + + + + + )} + + {isMentor && ( + <> + + + + + Сдано / всего + + {hw.total_submissions || 0} сдано + {hw.checked_submissions > 0 && ` · ${hw.checked_submissions} проверено`} + + + + + {hw.average_score > 0 && ( + <> + + + + + Средняя оценка + + + {hw.average_score.toFixed(1)} + + + + + )} + + )} + + + + + + + + ); +} diff --git a/front_minimal/src/sections/homework/view/homework-view.jsx b/front_minimal/src/sections/homework/view/homework-view.jsx index 1a522a5..53ee58b 100644 --- a/front_minimal/src/sections/homework/view/homework-view.jsx +++ b/front_minimal/src/sections/homework/view/homework-view.jsx @@ -13,14 +13,15 @@ import CardContent from '@mui/material/CardContent'; import CardActionArea from '@mui/material/CardActionArea'; import CircularProgress from '@mui/material/CircularProgress'; -import { paths } from 'src/routes/paths'; +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; -import { getHomework, getHomeworkById, getHomeworkStatus } from 'src/utils/homework-api'; +import { getHomework, getHomeworkById, getHomeworkStatus } from 'src/utils/homework-api'; -import { DashboardContent } from 'src/layouts/dashboard'; +import { DashboardContent } from 'src/layouts/dashboard'; import { Iconify } from 'src/components/iconify'; -import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; +import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; import { useAuthContext } from 'src/auth/hooks'; @@ -85,12 +86,30 @@ function HomeworkCard({ hw, userRole, onView, onSubmit, onEdit }) { )} - {userRole === 'mentor' && hw.total_submissions > 0 && ( - - Решений: {hw.total_submissions} - {hw.checked_submissions > 0 && ` • Проверено: ${hw.checked_submissions}`} - - )} + {userRole === 'mentor' && (() => { + const assignedCount = Array.isArray(hw.assigned_to) ? hw.assigned_to.length : 0; + const isGroup = assignedCount > 1; + return ( + + {isGroup && ( + } + label={`Группа • ${assignedCount} уч.`} + variant="outlined" + color="primary" + sx={{ height: 20, fontSize: 11 }} + /> + )} + {hw.total_submissions > 0 && ( + + Решений: {hw.total_submissions} + {hw.checked_submissions > 0 && ` • Проверено: ${hw.checked_submissions}`} + + )} + + ); + })()} {hw.deadline && ( @@ -196,8 +215,23 @@ function HomeworkColumn({ title, items, userRole, onView, onSubmit, onEdit, empt export function HomeworkView() { const { user } = useAuthContext(); + const router = useRouter(); const userRole = user?.role ?? ''; + const getChildId = () => { + if (userRole !== 'parent') return null; + try { const s = localStorage.getItem('selected_child'); return s ? (JSON.parse(s)?.id || null) : null; } catch { return null; } + }; + const [childId, setChildId] = useState(getChildId); + + useEffect(() => { + if (userRole !== 'parent') return undefined; + const handler = () => setChildId(getChildId()); + window.addEventListener('child-changed', handler); + return () => window.removeEventListener('child-changed', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userRole]); + const [homework, setHomework] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -213,28 +247,24 @@ export function HomeworkView() { const loadHomework = useCallback(async () => { try { setLoading(true); - const res = await getHomework({ page_size: 1000 }); + const params = { page_size: 1000 }; + if (childId) params.child_id = childId; + const res = await getHomework(params); setHomework(res.results); } catch (e) { setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки'); } finally { setLoading(false); } - }, []); + }, [childId]); useEffect(() => { loadHomework(); }, [loadHomework]); - const handleViewDetails = useCallback(async (hw) => { - try { - const full = await getHomeworkById(hw.id); - setSelectedHw(full); - } catch { - setSelectedHw(hw); - } - setDetailsOpen(true); - }, []); + const handleViewDetails = useCallback((hw) => { + router.push(paths.dashboard.homeworkDetail(hw.id)); + }, [router]); const handleOpenSubmit = useCallback((hw) => { setSubmitHwId(hw.id); diff --git a/front_minimal/src/sections/lesson-detail/view/index.js b/front_minimal/src/sections/lesson-detail/view/index.js new file mode 100644 index 0000000..5972cbc --- /dev/null +++ b/front_minimal/src/sections/lesson-detail/view/index.js @@ -0,0 +1 @@ +export { LessonDetailView } from './lesson-detail-view'; diff --git a/front_minimal/src/sections/lesson-detail/view/lesson-detail-view.jsx b/front_minimal/src/sections/lesson-detail/view/lesson-detail-view.jsx new file mode 100644 index 0000000..ff486be --- /dev/null +++ b/front_minimal/src/sections/lesson-detail/view/lesson-detail-view.jsx @@ -0,0 +1,1028 @@ +'use client'; + +import { useRef, useState, useEffect } from 'react'; + +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Chip from '@mui/material/Chip'; +import Alert from '@mui/material/Alert'; +import Stack from '@mui/material/Stack'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import Rating from '@mui/material/Rating'; +import Divider from '@mui/material/Divider'; +import Tooltip from '@mui/material/Tooltip'; +import Skeleton from '@mui/material/Skeleton'; +import TextField from '@mui/material/TextField'; +import IconButton from '@mui/material/IconButton'; +import Typography from '@mui/material/Typography'; +import LoadingButton from '@mui/lab/LoadingButton'; +import CardContent from '@mui/material/CardContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { createLiveKitRoom } from 'src/utils/livekit-api'; +import { getOrCreateLessonBoard } from 'src/utils/board-api'; +import { getLesson, completeLesson, getLessonFiles, uploadLessonFile, deleteLessonFile, updateCalendarLesson } from 'src/utils/dashboard-api'; +import { getHomework, createHomework, publishHomework, uploadHomeworkFile } from 'src/utils/homework-api'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { Iconify } from 'src/components/iconify'; + +import { useAuthContext } from 'src/auth/hooks'; + +// ---------------------------------------------------------------------- + +const STATUS_CONFIG = { + completed: { label: 'Завершено', color: 'success' }, + in_progress: { label: 'Идёт', color: 'warning' }, + ongoing: { label: 'Идёт', color: 'warning' }, + cancelled: { label: 'Отменено', color: 'error' }, + planned: { label: 'Запланировано', color: 'default' }, + scheduled: { label: 'Запланировано', color: 'default' }, + upcoming: { label: 'Скоро', color: 'info' }, +}; + +const NON_PLANNED = ['completed', 'in_progress', 'ongoing', 'cancelled']; + +function statusCfg(status) { + return STATUS_CONFIG[String(status || '').toLowerCase()] ?? { label: status || '—', color: 'default' }; +} + +function formatDate(s) { + if (!s) return '—'; + return new Date(s).toLocaleDateString('ru-RU', { + weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', + }); +} + +function formatTime(s) { + if (!s) return ''; + return new Date(s).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); +} + +function getPersonName(person) { + if (!person) return '—'; + if (typeof person === 'object') { + const u = person.user || person; + const name = `${u.first_name || ''} ${u.last_name || ''}`.trim(); + return name || u.email || `ID: ${person.id ?? '?'}`; + } + return `ID: ${person}`; +} + +function getPersonAvatar(person) { + if (!person || typeof person !== 'object') return null; + const u = person.user || person; + return u.avatar_url || u.avatar || null; +} + +function getPersonInitials(person) { + return getPersonName(person) + .split(' ') + .filter(Boolean) + .slice(0, 2) + .map((w) => w[0]?.toUpperCase()) + .join(''); +} + +// Designed for future group lessons: lesson.clients[] → single client fallback +function getParticipants(lesson) { + if (Array.isArray(lesson.clients) && lesson.clients.length) return lesson.clients; + if (lesson.client) return [lesson.client]; + return []; +} + +function getSubjectName(lesson) { + if (lesson.subject_name) return lesson.subject_name; + if (typeof lesson.subject === 'string') return lesson.subject; + if (lesson.subject?.name) return lesson.subject.name; + return 'Занятие'; +} + +function getFileIcon(name = '', mime = '') { + const n = name.toLowerCase(); + const m = mime.toLowerCase(); + if (m.startsWith('image/') || /\.(jpe?g|png|gif|webp|svg)$/.test(n)) return 'solar:gallery-bold'; + if (m.startsWith('video/') || /\.(mp4|webm|mov|avi)$/.test(n)) return 'solar:videocamera-bold'; + if (m === 'application/pdf' || n.endsWith('.pdf')) return 'solar:file-text-bold'; + if (/\.(doc|docx)$/.test(n)) return 'solar:file-bold'; + if (/\.(xls|xlsx)$/.test(n)) return 'solar:file-bold'; + if (/\.(zip|rar|7z)$/.test(n)) return 'solar:zip-file-bold'; + return 'solar:document-bold'; +} + +function formatFileSize(bytes) { + if (!bytes) return ''; + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} МБ`; + if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} КБ`; + return `${bytes} Б`; +} + +// ---------------------------------------------------------------------- + +function FileItem({ file, isMentor, onDeleted }) { + const [deleting, setDeleting] = useState(false); + + const name = file.file_name || file.name || (file.file ? String(file.file).split('/').pop() : null) || 'Файл'; + const url = file.file_url || file.file || file.url; + const mime = file.mime_type || file.content_type || ''; + const size = file.file_size || file.size; + + const handleDelete = async () => { + setDeleting(true); + try { + await deleteLessonFile(file.id); + onDeleted(file.id); + } catch { /* ignore */ } finally { + setDeleting(false); + } + }; + + return ( + + + + + + + {name} + {size > 0 && ( + {formatFileSize(size)} + )} + + + {url && ( + + + + + + )} + + {isMentor && ( + + + + + + )} + + ); +} + +// ---------------------------------------------------------------------- + +function LessonFilesSection({ lessonId, isMentor, isActive }) { + const fileInputRef = useRef(null); + const [files, setFiles] = useState([]); + const [loadingFiles, setLoadingFiles] = useState(true); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + + useEffect(() => { + if (!lessonId) return; + getLessonFiles(lessonId) + .then((data) => setFiles(Array.isArray(data) ? data : (data?.results ?? []))) + .catch(() => {}) + .finally(() => setLoadingFiles(false)); + }, [lessonId]); + + const handleUpload = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + if (file.size > 30 * 1024 * 1024) { + setUploadError('Файл превышает 30 МБ'); + e.target.value = ''; + return; + } + setUploading(true); + setUploadError(null); + try { + const created = await uploadLessonFile(lessonId, file); + setFiles((prev) => [...prev, created]); + } catch (err) { + setUploadError(err?.response?.data?.detail || err?.message || 'Не удалось загрузить файл'); + } finally { + setUploading(false); + e.target.value = ''; + } + }; + + if (loadingFiles) return ; + if (!isMentor && files.length === 0) return null; + + return ( + + + + + Файлы урока + {files.length > 0 && ( + + )} + + + {isMentor && isActive && ( + <> + + } + onClick={() => fileInputRef.current?.click()} + > + Загрузить + + + )} + + + {uploadError && {uploadError}} + + {files.length === 0 ? ( + + + Файлы не добавлены + + ) : ( + + {files.map((f) => ( + setFiles((prev) => prev.filter((x) => x.id !== id))} + /> + ))} + + )} + + + ); +} + +// ---------------------------------------------------------------------- + +function ParticipantRow({ person, roleLabel, grade, schoolGrade, showGrades }) { + return ( + + + {getPersonInitials(person)} + + + + {getPersonName(person)} + {roleLabel} + + + {showGrades && ( + + {grade != null && ( + + Успех: + + + )} + {schoolGrade != null && ( + + Школа: + + + )} + + )} + + ); +} + +// ---------------------------------------------------------------------- + +function ResultsSection({ lesson, isMentor, onSaved }) { + const hasResults = !!(lesson.mentor_grade || lesson.school_grade || lesson.mentor_notes); + const isCompleted = NON_PLANNED.includes(String(lesson.status || '').toLowerCase()); + + const [editing, setEditing] = useState(!hasResults && isMentor && lesson.status !== 'cancelled'); + const [mentorGrade, setMentorGrade] = useState(lesson.mentor_grade ?? 0); + const [schoolGrade, setSchoolGrade] = useState(lesson.school_grade ?? 0); + const [notes, setNotes] = useState(lesson.mentor_notes ?? ''); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + if (lesson.status === 'cancelled') return null; + if (!isCompleted && !isMentor) return null; + + const handleSave = async () => { + setLoading(true); + setError(null); + try { + await completeLesson( + String(lesson.id), + notes.trim(), + mentorGrade > 0 ? mentorGrade : undefined, + schoolGrade > 0 ? schoolGrade : undefined, + ); + setEditing(false); + onSaved({ mentor_grade: mentorGrade, school_grade: schoolGrade, mentor_notes: notes }); + } catch (e) { + setError(e?.response?.data?.detail || e?.message || 'Не удалось сохранить'); + } finally { + setLoading(false); + } + }; + + return ( + + + + Итог занятия + {isMentor && hasResults && !editing && ( + + )} + + + {/* Read-only */} + {!editing && ( + + {!hasResults ? ( + + + Итог ещё не заполнен + + ) : ( + <> + + + + Успеваемость + + + + + + Школьная оценка + + + + + + {lesson.mentor_notes && ( + + + {lesson.mentor_notes} + + + )} + + )} + + )} + + {/* Edit form */} + {editing && isMentor && ( + + {error && {error}} + + + + + Успеваемость (1–5) + + setMentorGrade(v ?? 0)} /> + + + + Школьная оценка (1–5) + + setSchoolGrade(v ?? 0)} /> + + + + setNotes(e.target.value)} + multiline + rows={3} + fullWidth + /> + + + {hasResults && ( + + )} + } + > + Сохранить + + + + )} + + + ); +} + +// ---------------------------------------------------------------------- + +function LessonSkeleton() { + return ( + + + + + + + + + + + + + + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +export function LessonDetailView({ id }) { + const router = useRouter(); + const { user } = useAuthContext(); + + const [lesson, setLesson] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [joiningVideo, setJoiningVideo] = useState(false); + const [openingBoard, setOpeningBoard] = useState(false); + const [openingHw, setOpeningHw] = useState(false); + const [lessonHw, setLessonHw] = useState(undefined); // undefined=loading, null=not found, object=found + const [createHwOpen, setCreateHwOpen] = useState(false); + const [createHwDesc, setCreateHwDesc] = useState(''); + const [createHwDeadline, setCreateHwDeadline] = useState(''); + const [createHwFiles, setCreateHwFiles] = useState([]); + const [createHwLoading, setCreateHwLoading] = useState(false); + const [createHwError, setCreateHwError] = useState(null); + const createHwFileRef = useRef(null); + const [cancelDialogOpen, setCancelDialogOpen] = useState(false); + const [cancelLoading, setCancelLoading] = useState(false); + const [cancelError, setCancelError] = useState(null); + + useEffect(() => { + if (!id || id === 'undefined' || id === 'null') { + setError('Некорректный ID занятия'); + setLoading(false); + return; + } + setLoading(true); + getLesson(id) + .then((data) => { + const fetched = data?.data ?? data; + if (!fetched || typeof fetched !== 'object' || !fetched.id) { + setError('Занятие не найдено'); + return; + } + setLesson(fetched); + // Загружаем ДЗ для этого урока + getHomework({ lesson_id: fetched.id, page_size: 1 }) + .then((res) => setLessonHw(res.results?.[0] ?? null)) + .catch(() => setLessonHw(null)); + }) + .catch((e) => { + const status = e?.response?.status; + if (status === 404 || status === 403) { + setError('Занятие не найдено или у вас нет доступа к нему'); + } else { + setError(e?.response?.data?.detail || e?.message || 'Не удалось загрузить занятие'); + } + }) + .finally(() => setLoading(false)); + }, [id]); + + if (loading) return ; + + if (error || !lesson) { + return ( + + {error || 'Занятие не найдено'} + + + ); + } + + const isMentor = user?.role === 'mentor'; + const participants = getParticipants(lesson); + const subjectName = getSubjectName(lesson); + const sc = statusCfg(lesson.status); + const statusKey = String(lesson.status || '').toLowerCase(); + + const timeStr = lesson.start_time && lesson.end_time + ? `${formatTime(lesson.start_time)} – ${formatTime(lesson.end_time)}` + : formatTime(lesson.start_time); + + const canJoin = (() => { + if (!['in_progress', 'ongoing', 'upcoming', 'planned', 'scheduled'].includes(statusKey)) return false; + const now = new Date(); + const start = lesson.start_time ? new Date(lesson.start_time) : null; + const end = lesson.end_time ? new Date(lesson.end_time) : null; + const started = start && start <= new Date(now.getTime() + 11 * 60000); + const notOver = !end || now < new Date(end.getTime() + 15 * 60000); + return started && notOver; + })(); + + const canCancel = isMentor && ['planned', 'scheduled', 'upcoming'].includes(statusKey); + const isActive = !['completed', 'cancelled'].includes(statusKey); + + const handleJoinVideo = async () => { + setJoiningVideo(true); + try { + const room = await createLiveKitRoom(lesson.id); + const token = room.access_token || room.token; + router.push(`${paths.dashboard.prejoin}?token=${encodeURIComponent(token)}&lesson_id=${lesson.id}`); + } catch { /* ignore */ } finally { + setJoiningVideo(false); + } + }; + + const handleOpenHomework = async () => { + setOpeningHw(true); + try { + const res = await getHomework({ lesson_id: lesson.id, page_size: 1 }); + const hw = res.results?.[0]; + if (hw?.id) { + router.push(paths.dashboard.homeworkDetail(hw.id)); + } else { + router.push(paths.dashboard.homework); + } + } catch { + router.push(paths.dashboard.homework); + } finally { + setOpeningHw(false); + } + }; + + const handleOpenBoard = async () => { + setOpeningBoard(true); + try { + const board = await getOrCreateLessonBoard(lesson.id); + const boardId = board.board_id || board.id; + router.push(`${paths.dashboard.board}?id=${boardId}`); + } catch { /* ignore */ } finally { + setOpeningBoard(false); + } + }; + + const handleConfirmCancel = async () => { + setCancelLoading(true); + setCancelError(null); + try { + await updateCalendarLesson(lesson.id, { status: 'cancelled' }); + setLesson((prev) => ({ ...prev, status: 'cancelled' })); + setCancelDialogOpen(false); + } catch (e) { + setCancelError(e?.response?.data?.detail || e?.message || 'Не удалось отменить занятие'); + } finally { + setCancelLoading(false); + } + }; + + const handleCreateHwFileChange = (e) => { + const selected = Array.from(e.target.files || []); + e.target.value = ''; + const valid = selected.filter((f) => { + if (f.size > 10 * 1024 * 1024) { + setCreateHwError(`Файл "${f.name}" больше 10 МБ`); + return false; + } + return true; + }); + setCreateHwFiles((prev) => [...prev, ...valid]); + }; + + const handleCreateHw = async () => { + setCreateHwLoading(true); + setCreateHwError(null); + try { + const autoTitle = `${getSubjectName(lesson)} ${new Date(lesson.start_time).toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: 'numeric' })}`; + const hw = await createHomework({ + title: autoTitle, + description: createHwDesc.trim(), + lesson_id: lesson.id, + ...(createHwDeadline && { due_date: new Date(createHwDeadline).toISOString() }), + status: 'draft', + }); + if (createHwFiles.length > 0) { + await Promise.all(createHwFiles.map((f) => uploadHomeworkFile(hw.id, f))); + } + await publishHomework(hw.id); + setLessonHw({ ...hw, status: 'published' }); + setCreateHwOpen(false); + setCreateHwDesc(''); + setCreateHwDeadline(''); + setCreateHwFiles([]); + } catch (e) { + setCreateHwError(e?.response?.data?.detail || e?.message || 'Не удалось создать ДЗ'); + } finally { + setCreateHwLoading(false); + } + }; + + const infoItems = [ + { icon: 'solar:calendar-bold', label: 'Дата', value: formatDate(lesson.start_time) }, + { icon: 'solar:clock-circle-bold', label: 'Время', value: timeStr }, + { icon: 'solar:hourglass-bold', label: 'Длительность', value: lesson.duration ? `${lesson.duration} мин` : '—' }, + { icon: 'solar:book-bold', label: 'Предмет', value: subjectName }, + ...(lesson.price != null + ? [{ icon: 'solar:wallet-bold', label: 'Стоимость', value: `${lesson.price} ₽` }] + : []), + ]; + + return ( + + + {/* Breadcrumb */} + + router.push(paths.dashboard.calendar)}> + + + router.push(paths.dashboard.calendar)} + > + Расписание + + + + {/* Header */} + + + + {subjectName} + + + + {formatDate(lesson.start_time)} · {timeStr} + + + + + {canCancel && ( + + )} + + {canJoin && ( + } + > + Войти в урок + + )} + + + + {/* Content grid */} + + + {/* Left column */} + + setLesson((prev) => ({ ...prev, ...updated }))} + /> + + + + {/* Homework card */} + + + + + + + + + Домашнее задание + + {lessonHw === undefined && 'Загрузка...'} + {lessonHw === null && (isMentor ? 'Не назначено' : 'Нет домашнего задания')} + {lessonHw && lessonHw.title} + + + + {lessonHw === undefined && null} + {lessonHw === null && isMentor && ( + + )} + {lessonHw && ( + } + onClick={handleOpenHomework} + > + Открыть + + )} + + + + + + {/* Right column */} + + + {/* Info */} + + + Детали + + {infoItems.map(({ icon, label, value }) => ( + + + + {label} + + {value} + + ))} + + + + + {/* Whiteboard card */} + + + + + + + + + Доска + Совместная доска урока + + + } + onClick={handleOpenBoard} + > + Открыть + + + + + + {/* Participants */} + + + + Участники + {participants.length > 1 && ( + + )} + + + }> + {lesson.mentor && ( + + )} + + {participants.map((p, i) => ( + 1 ? `Ученик ${i + 1}` : 'Ученик'} + grade={lesson.status === 'completed' ? lesson.mentor_grade : null} + schoolGrade={lesson.status === 'completed' ? lesson.school_grade : null} + showGrades={lesson.status === 'completed'} + /> + ))} + + {!lesson.mentor && participants.length === 0 && ( + Нет данных + )} + + + + + + + + {/* Create homework dialog */} + !createHwLoading && setCreateHwOpen(false)} maxWidth="sm" fullWidth> + Создать домашнее задание + + + {createHwError && setCreateHwError(null)}>{createHwError}} + setCreateHwDesc(e.target.value)} + fullWidth + multiline + rows={3} + disabled={createHwLoading} + placeholder="Что нужно сделать..." + /> + setCreateHwDeadline(e.target.value)} + fullWidth + disabled={createHwLoading} + InputLabelProps={{ shrink: true }} + /> + + {/* Files */} + + + Файлы + + + + + {createHwFiles.length === 0 ? ( + Файлы не выбраны + ) : ( + + {createHwFiles.map((f, i) => ( + + + {f.name} + + {f.size >= 1024 * 1024 ? `${(f.size / 1024 / 1024).toFixed(1)} МБ` : `${(f.size / 1024).toFixed(0)} КБ`} + + setCreateHwFiles((prev) => prev.filter((_, j) => j !== i))} disabled={createHwLoading}> + + + + ))} + + )} + + + + + + + Создать и опубликовать + + + + + {/* Cancel confirmation dialog */} + !cancelLoading && setCancelDialogOpen(false)} + maxWidth="xs" + fullWidth + > + Отменить занятие? + + {cancelError && {cancelError}} + + Занятие «{subjectName}» {formatDate(lesson.start_time)} будет отменено. Это действие нельзя отменить. + + + + + + Отменить занятие + + + + + + ); +} diff --git a/front_minimal/src/sections/my-progress/view/my-progress-view.jsx b/front_minimal/src/sections/my-progress/view/my-progress-view.jsx index d3181fd..eb62b17 100644 --- a/front_minimal/src/sections/my-progress/view/my-progress-view.jsx +++ b/front_minimal/src/sections/my-progress/view/my-progress-view.jsx @@ -1,7 +1,7 @@ 'use client'; - -import { useMemo, useState, useEffect, useCallback } from 'react'; + +import { useMemo, useState, useEffect, useCallback } from 'react'; import Box from '@mui/material/Box'; import Card from '@mui/material/Card'; @@ -12,17 +12,17 @@ import MenuItem from '@mui/material/MenuItem'; import Typography from '@mui/material/Typography'; import InputLabel from '@mui/material/InputLabel'; import FormControl from '@mui/material/FormControl'; -import CardContent from '@mui/material/CardContent'; +import CardContent from '@mui/material/CardContent'; import CircularProgress from '@mui/material/CircularProgress'; import { paths } from 'src/routes/paths'; import axios from 'src/utils/axios'; -import { DashboardContent } from 'src/layouts/dashboard'; +import { DashboardContent } from 'src/layouts/dashboard'; import { Iconify } from 'src/components/iconify'; -import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; +import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; import { useAuthContext } from 'src/auth/hooks'; @@ -56,7 +56,7 @@ async function getLessons(params) { if (params?.start_date) q.append('start_date', params.start_date); if (params?.end_date) q.append('end_date', params.end_date); if (params?.child_id) q.append('child_id', params.child_id); - const res = await axios.get(`/lessons/lessons/?${q.toString()}`); + const res = await axios.get(`/schedule/lessons/?${q.toString()}`); const {data} = res; if (Array.isArray(data)) return { results: data }; return data; @@ -116,11 +116,19 @@ export function MyProgressView() { const [subjects, setSubjects] = useState([]); const [selectedSubject, setSelectedSubject] = useState(''); - const childId = isParent - ? typeof window !== 'undefined' - ? localStorage.getItem('selected_child_id') || '' - : '' - : ''; + const getChildId = () => { + if (!isParent) return ''; + try { const s = localStorage.getItem('selected_child'); return s ? (JSON.parse(s)?.id || '') : ''; } catch { return ''; } + }; + const [childId, setChildId] = useState(getChildId); + + useEffect(() => { + if (!isParent) return undefined; + const handler = () => setChildId(getChildId()); + window.addEventListener('child-changed', handler); + return () => window.removeEventListener('child-changed', handler); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isParent]); const loadSubjects = useCallback(async () => { const sixMonthsAgo = new Date(Date.now() - 180 * 86400000).toISOString().slice(0, 10); diff --git a/front_minimal/src/sections/overview/client/view/overview-client-view.jsx b/front_minimal/src/sections/overview/client/view/overview-client-view.jsx index 6857515..d53fb5d 100644 --- a/front_minimal/src/sections/overview/client/view/overview-client-view.jsx +++ b/front_minimal/src/sections/overview/client/view/overview-client-view.jsx @@ -13,6 +13,7 @@ import CircularProgress from '@mui/material/CircularProgress'; import LinearProgress from '@mui/material/LinearProgress'; import { useTheme } from '@mui/material/styles'; +import { paths } from 'src/routes/paths'; import { useRouter } from 'src/routes/hooks'; import { fDateTime } from 'src/utils/format-time'; import { getClientDashboard, getChildDashboard } from 'src/utils/dashboard-api'; @@ -66,7 +67,7 @@ function LessonItem({ lesson }) { setJoining(true); const res = await createLiveKitRoom(lesson.id); const token = res?.access_token || res?.token; - router.push(`/video-call?token=${token}&lesson_id=${lesson.id}`); + router.push(`${paths.dashboard.prejoin}?token=${encodeURIComponent(token)}&lesson_id=${lesson.id}`); } catch (err) { console.error('Join error:', err); setJoining(false); @@ -203,6 +204,16 @@ export function OverviewClientView({ childId, childName }) { return () => controller.abort(); }, [fetchData]); + // Периодическое обновление каждые 60 сек — кнопка «Подключиться» появляется автоматически + useEffect(() => { + const id = setInterval(() => { + const controller = new AbortController(); + fetchData(controller.signal); + return () => controller.abort(); + }, 60_000); + return () => clearInterval(id); + }, [fetchData]); + const displayName = childName || user?.first_name || 'Студент'; const completionPct = diff --git a/front_minimal/src/sections/prejoin/view/prejoin-view.jsx b/front_minimal/src/sections/prejoin/view/prejoin-view.jsx new file mode 100644 index 0000000..3d663e4 --- /dev/null +++ b/front_minimal/src/sections/prejoin/view/prejoin-view.jsx @@ -0,0 +1,373 @@ +import { useRef, useState, useEffect, useCallback } from 'react'; +import { useSearchParams, useNavigate } from 'react-router-dom'; + +import Box from '@mui/material/Box'; +import Card from '@mui/material/Card'; +import Chip from '@mui/material/Chip'; +import Stack from '@mui/material/Stack'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import Container from '@mui/material/Container'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import LoadingButton from '@mui/lab/LoadingButton'; +import FormControl from '@mui/material/FormControl'; +import Divider from '@mui/material/Divider'; + +import { paths } from 'src/routes/paths'; +import { varAlpha } from 'src/theme/styles'; +import { Iconify } from 'src/components/iconify'; +import { useSettingsContext } from 'src/components/settings'; +import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; + +const LS_AUDIO_ENABLED = 'videoConference_audioEnabled'; +const LS_VIDEO_ENABLED = 'videoConference_videoEnabled'; + +// ---------------------------------------------------------------------- + +function useMediaDeviceList(kind) { + const [devices, setDevices] = useState([]); + const [activeId, setActiveId] = useState(''); + + useEffect(() => { + const load = async () => { + try { + const all = await navigator.mediaDevices.enumerateDevices(); + const filtered = all.filter((d) => d.kind === kind && d.deviceId); + setDevices(filtered); + if (filtered.length > 0 && !activeId) setActiveId(filtered[0].deviceId); + } catch { /* ignore */ } + }; + load(); + navigator.mediaDevices.addEventListener('devicechange', load); + return () => navigator.mediaDevices.removeEventListener('devicechange', load); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [kind]); + + return { devices, activeId, setActiveId }; +} + +// ---------------------------------------------------------------------- + +export function PrejoinView() { + const navigate = useNavigate(); + const settings = useSettingsContext(); + const [searchParams] = useSearchParams(); + + const token = searchParams.get('token') || ''; + const lessonId = searchParams.get('lesson_id') || ''; + + const [camEnabled, setCamEnabled] = useState(true); + const [micEnabled, setMicEnabled] = useState(true); + const [joining, setJoining] = useState(false); + + const videoRef = useRef(null); + const streamRef = useRef(null); + + const { devices: camDevices, activeId: camDeviceId, setActiveId: setCamDeviceId } = useMediaDeviceList('videoinput'); + const { devices: micDevices, activeId: micDeviceId, setActiveId: setMicDeviceId } = useMediaDeviceList('audioinput'); + + // Start/stop camera preview + const startCamera = useCallback(async (deviceId) => { + // Stop existing stream first + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + } + if (!videoRef.current) return; + try { + const constraints = { video: deviceId ? { deviceId: { exact: deviceId } } : true, audio: false }; + const stream = await navigator.mediaDevices.getUserMedia(constraints); + streamRef.current = stream; + if (videoRef.current) videoRef.current.srcObject = stream; + } catch (err) { + console.error('Camera error:', err); + } + }, []); + + const stopCamera = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + } + if (videoRef.current) videoRef.current.srcObject = null; + }, []); + + // Start camera on mount and when device changes + useEffect(() => { + if (camEnabled) { + startCamera(camDeviceId); + } else { + stopCamera(); + } + return () => { if (!camEnabled) stopCamera(); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [camEnabled, camDeviceId]); + + // Cleanup on unmount + useEffect(() => () => stopCamera(), [stopCamera]); + + const handleJoin = () => { + setJoining(true); + stopCamera(); + try { + localStorage.setItem(LS_AUDIO_ENABLED, String(micEnabled)); + localStorage.setItem(LS_VIDEO_ENABLED, String(camEnabled)); + if (camDeviceId) localStorage.setItem('videoConference_videoDeviceId', camDeviceId); + if (micDeviceId) localStorage.setItem('videoConference_audioDeviceId', micDeviceId); + } catch { /* ignore */ } + const params = new URLSearchParams({ token, lesson_id: lessonId, skip_prejoin: '1' }); + navigate(`${paths.videoCall}?${params.toString()}`); + }; + + return ( + + + + Подключение к занятию + + + + + + {/* Left — camera preview */} + varAlpha(t.vars.palette.grey['500Channel'], 0.04), + borderRight: { md: '1px solid' }, + borderColor: { md: 'divider' }, + }} + > + {/* Video preview */} + + + + {/* Toggle buttons */} + + + setMicEnabled((v) => !v)} + sx={{ + width: 52, + height: 52, + bgcolor: micEnabled ? 'primary.main' : 'error.main', + color: 'common.white', + '&:hover': { bgcolor: micEnabled ? 'primary.dark' : 'error.dark' }, + }} + > + + + + {micEnabled ? 'Микрофон вкл' : 'Микрофон выкл'} + + + + + setCamEnabled((v) => !v)} + sx={{ + width: 52, + height: 52, + bgcolor: camEnabled ? 'primary.main' : 'error.main', + color: 'common.white', + '&:hover': { bgcolor: camEnabled ? 'primary.dark' : 'error.dark' }, + }} + > + + + + {camEnabled ? 'Камера вкл' : 'Камера выкл'} + + + + + + {/* Right — settings + join */} + + + + + Настройте оборудование + + + Проверьте камеру и микрофон перед входом в занятие. + + + + + + {/* Mic */} + + + + Микрофон + + + + {micDevices.length > 1 && ( + + + + )} + + + {/* Camera */} + + + + Камера + + + + {camDevices.length > 1 && ( + + + + )} + + + + + varAlpha(t.vars.palette.info.mainChannel, 0.08), + border: '1px solid', + borderColor: (t) => varAlpha(t.vars.palette.info.mainChannel, 0.2), + }} + > + + Вы можете изменить настройки камеры и микрофона в любой момент во время занятия. + + + + + } + sx={{ mt: 1 }} + > + Подключиться + + + + + + + ); +} diff --git a/front_minimal/src/sections/students/view/student-detail-view.jsx b/front_minimal/src/sections/students/view/student-detail-view.jsx new file mode 100644 index 0000000..c373fa0 --- /dev/null +++ b/front_minimal/src/sections/students/view/student-detail-view.jsx @@ -0,0 +1,713 @@ +import { useParams } from 'react-router-dom'; +import { useState, useEffect, useCallback } from 'react'; + +import Box from '@mui/material/Box'; +import Tab from '@mui/material/Tab'; +import Card from '@mui/material/Card'; +import Chip from '@mui/material/Chip'; +import Tabs from '@mui/material/Tabs'; +import Alert from '@mui/material/Alert'; +import Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import TableRow from '@mui/material/TableRow'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableHead from '@mui/material/TableHead'; +import TextField from '@mui/material/TextField'; +import Typography from '@mui/material/Typography'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import DialogActions from '@mui/material/DialogActions'; +import LinearProgress from '@mui/material/LinearProgress'; +import TableContainer from '@mui/material/TableContainer'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; + +import { resolveMediaUrl } from 'src/utils/axios'; +import { getStudents } from 'src/utils/students-api'; +import { + completeLesson, + getStudentLessons, + getStudentProgress, +} from 'src/utils/student-progress-api'; + +import { DashboardContent } from 'src/layouts/dashboard'; + +import { Iconify } from 'src/components/iconify'; +import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; + +// ---------------------------------------------------------------------- + +const avatarSrc = (href) => resolveMediaUrl(href) || null; + +function initials(u) { + return `${(u?.first_name || '')[0] || ''}${(u?.last_name || '')[0] || ''}`.toUpperCase() || '?'; +} + +function fDate(str) { + if (!str) return '—'; + try { + const d = new Date(str); + return d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' }); + } catch { return '—'; } +} + +function fDateTime(str) { + if (!str) return '—'; + try { + const d = new Date(str); + return d.toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + } catch { return '—'; } +} + +function gradeColor(grade) { + if (!grade && grade !== 0) return 'default'; + if (grade >= 80) return 'success'; + if (grade >= 60) return 'warning'; + return 'error'; +} + +// ---------------------------------------------------------------------- +// Feedback dialog + +function FeedbackDialog({ lesson, open, onClose, onSaved }) { + const [notes, setNotes] = useState(''); + const [mentorGrade, setMentorGrade] = useState(''); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (open && lesson) { + setNotes(lesson.mentor_notes || ''); + setMentorGrade(lesson.mentor_grade ?? ''); + setError(null); + } + }, [open, lesson]); + + const handleSave = async () => { + try { + setSaving(true); + setError(null); + await completeLesson(lesson.id, { + notes, + mentor_grade: mentorGrade !== '' ? Number(mentorGrade) : undefined, + }); + onSaved(); + onClose(); + } catch (e) { + setError(e?.response?.data?.error?.message || e?.response?.data?.detail || 'Ошибка сохранения'); + } finally { + setSaving(false); + } + }; + + if (!lesson) return null; + return ( + + Обратная связь по уроку + + + + {fDateTime(lesson.start_time)} · {lesson.title || 'Занятие'} + + {error && {error}} + setNotes(e.target.value)} + multiline + minRows={3} + fullWidth + /> + setMentorGrade(e.target.value)} + type="number" + inputProps={{ min: 0, max: 100 }} + fullWidth + /> + + + + + + + + ); +} + +// ---------------------------------------------------------------------- +// Progress tab + +const MAX_DAYS = 365; + +function toISO(date) { + return date instanceof Date ? date.toISOString().slice(0, 10) : date; +} + +function daysBetween(a, b) { + return Math.round((new Date(b) - new Date(a)) / 86400000); +} + +function defaultRange() { + const end = new Date(); + const start = new Date(); + start.setMonth(end.getMonth() - 1); + return { start: toISO(start), end: toISO(end) }; +} + +function ProgressTab({ clientId }) { + const [progress, setProgress] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [subject, setSubject] = useState(''); + const [range, setRange] = useState(defaultRange); + const [rangeError, setRangeError] = useState(null); + + const presets = [ + { label: '7 дней', days: 7 }, + { label: '30 дней', days: 30 }, + { label: '3 месяца', days: 90 }, + { label: '6 месяцев', days: 180 }, + { label: 'Год', days: 365 }, + ]; + + const applyPreset = (days) => { + const end = new Date(); + const start = new Date(); + start.setDate(end.getDate() - days); + setRange({ start: toISO(start), end: toISO(end) }); + setRangeError(null); + }; + + const handleDateChange = (field, value) => { + const next = { ...range, [field]: value }; + if (next.start && next.end) { + const diff = daysBetween(next.start, next.end); + if (diff < 0) { setRangeError('Дата начала не может быть позже даты конца'); return; } + if (diff > MAX_DAYS) { setRangeError(`Период не может превышать ${MAX_DAYS} дней`); return; } + setRangeError(null); + } + setRange(next); + }; + + const load = useCallback(async () => { + if (rangeError) return; + try { + setLoading(true); + setError(null); + const params = {}; + if (range.start) params.start_date = range.start; + if (range.end) params.end_date = range.end; + if (subject) params.subject = subject; + const data = await getStudentProgress(clientId, params); + setProgress(data); + } catch (e) { + setError(e?.response?.data?.error?.message || e?.message || 'Ошибка загрузки'); + } finally { + setLoading(false); + } + }, [clientId, range, subject, rangeError]); + + useEffect(() => { load(); }, [load]); + + if (loading) return ; + if (error) return {error}; + if (!progress) return null; + + const overall = progress.overall || {}; + const subjects = progress.subjects || []; + const currentDays = range.start && range.end ? daysBetween(range.start, range.end) : null; + + return ( + + {/* Фильтры */} + + + {/* Быстрые пресеты */} + + {presets.map((p) => ( + applyPreset(p.days)} + variant={currentDays === p.days ? 'filled' : 'outlined'} + color={currentDays === p.days ? 'primary' : 'default'} + sx={{ cursor: 'pointer' }} + /> + ))} + + + {/* Произвольный диапазон */} + + handleDateChange('start', e.target.value)} + inputProps={{ max: range.end || toISO(new Date()) }} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + /> + handleDateChange('end', e.target.value)} + inputProps={{ min: range.start, max: toISO(new Date()) }} + InputLabelProps={{ shrink: true }} + sx={{ minWidth: 160 }} + /> + setSubject(e.target.value)} + placeholder="Математика..." + sx={{ minWidth: 180 }} + /> + + + + {rangeError && {rangeError}} + {currentDays != null && !rangeError && ( + + Выбрано: {currentDays} {currentDays === 1 ? 'день' : currentDays < 5 ? 'дня' : 'дней'} (макс. {MAX_DAYS}) + + )} + + + + {/* Общая статистика */} + + {[ + { label: 'Занятий', value: overall.total_lessons ?? 0, icon: 'solar:calendar-bold', color: 'primary.main' }, + { label: 'Завершено', value: overall.completed_lessons ?? 0, icon: 'solar:check-circle-bold', color: 'success.main' }, + { label: 'Средняя оценка', value: overall.average_grade ? `${Number(overall.average_grade).toFixed(1)}` : '—', icon: 'solar:star-bold', color: 'warning.main' }, + { label: 'Оценок выставлено', value: overall.total_grades ?? 0, icon: 'solar:pen-bold', color: 'info.main' }, + ].map((s) => ( + + + + + + + {s.value} + {s.label} + + + + ))} + + + {/* По предметам */} + {subjects.length > 0 && ( + + + По предметам + + + + + + Предмет + Занятий + Завершено + Ср. оценка + ДЗ всего + ДЗ сдано + Прогресс + + + + {subjects.map((sub) => { + const pct = sub.total_lessons > 0 ? Math.round((sub.completed_lessons / sub.total_lessons) * 100) : 0; + return ( + + {sub.subject || sub.title || '—'} + {sub.total_lessons ?? 0} + {sub.completed_lessons ?? 0} + + {sub.average_grade != null + ? + : '—'} + + {sub.total_homeworks ?? 0} + {sub.submitted_homeworks ?? 0} + + + + {pct}% + + + + ); + })} + +
+
+
+ )} + + {/* Последние оценки */} + {(progress.progress_timeline || []).length > 0 && ( + + + Последние оценки + + + + + + Дата + Тема + Оценка + Комментарий + + + + {progress.progress_timeline.slice(0, 10).map((item, i) => ( + + {fDate(item.date || item.created_at)} + {item.lesson_title || item.title || '—'} + + + + + + {item.notes || item.mentor_notes || '—'} + + + + ))} + +
+
+
+ )} +
+ ); +} + +// ---------------------------------------------------------------------- +// Lessons tab + +function LessonsTab({ clientId }) { + const [lessons, setLessons] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [status, setStatus] = useState(''); + const [feedbackLesson, setFeedbackLesson] = useState(null); + + const load = useCallback(async () => { + try { + setLoading(true); + const params = { page_size: 50, ordering: '-start_time' }; + if (status) params.status = status; + const res = await getStudentLessons(clientId, params); + setLessons(res.results); + } catch (e) { + setError(e?.message || 'Ошибка загрузки'); + } finally { + setLoading(false); + } + }, [clientId, status]); + + useEffect(() => { load(); }, [load]); + + const statusLabel = (s) => ({ + completed: { label: 'Завершено', color: 'success' }, + scheduled: { label: 'Запланировано', color: 'info' }, + cancelled: { label: 'Отменено', color: 'error' }, + }[s] || { label: s || '—', color: 'default' }); + + return ( + + {/* Фильтр статуса */} + + {[['', 'Все'], ['scheduled', 'Предстоящие'], ['completed', 'Завершённые'], ['cancelled', 'Отменённые']].map(([val, label]) => ( + setStatus(val)} + variant={status === val ? 'filled' : 'outlined'} + color={status === val ? 'primary' : 'default'} + sx={{ cursor: 'pointer' }} + /> + ))} + + + {error && {error}} + + {loading ? ( + + ) : lessons.length === 0 ? ( + + + Занятий не найдено + + ) : ( + + + + + + Дата + Тема + Статус + Оценка + Комментарий + Действия + + + + {lessons.map((lesson) => { + const st = statusLabel(lesson.status); + const needsFeedback = lesson.status === 'completed' && !lesson.mentor_notes && lesson.mentor_grade == null; + return ( + + + {fDateTime(lesson.start_time)} + + + {lesson.title || 'Занятие'} + {lesson.description && ( + + {lesson.description} + + )} + + + + + + {lesson.mentor_grade != null + ? + : '—'} + + + + {lesson.mentor_notes || '—'} + + + + {lesson.status === 'completed' && ( + + )} + + + ); + })} + +
+
+
+ )} + + setFeedbackLesson(null)} + onSaved={load} + /> +
+ ); +} + +// ---------------------------------------------------------------------- +// Feedback tab — уроки требующие обратной связи + +function FeedbackTab({ clientId }) { + const [lessons, setLessons] = useState([]); + const [loading, setLoading] = useState(true); + const [feedbackLesson, setFeedbackLesson] = useState(null); + + const load = useCallback(async () => { + try { + setLoading(true); + const res = await getStudentLessons(clientId, { status: 'completed', page_size: 100 }); + // Только те где нет оценки или комментария + setLessons(res.results.filter((l) => !l.mentor_notes && l.mentor_grade == null)); + } catch { /* ignore */ } finally { + setLoading(false); + } + }, [clientId]); + + useEffect(() => { load(); }, [load]); + + if (loading) return ; + + if (lessons.length === 0) { + return ( + + + Всё проверено! + Нет занятий, требующих обратной связи + + ); + } + + return ( + + }> + {lessons.length} {lessons.length === 1 ? 'занятие требует' : 'занятий требуют'} обратной связи + + + {lessons.map((lesson) => ( + + + + {lesson.title || 'Занятие'} + {fDateTime(lesson.start_time)} + + + + + ))} + + setFeedbackLesson(null)} + onSaved={load} + /> + + ); +} + +// ---------------------------------------------------------------------- +// Main view + +export function StudentDetailView() { + const { clientId } = useParams(); + const router = useRouter(); + + const [student, setStudent] = useState(null); + const [loading, setLoading] = useState(true); + const [tab, setTab] = useState('progress'); + + // Загружаем данные студента из списка + useEffect(() => { + getStudents({ page_size: 200 }).then((res) => { + const found = res.results.find((s) => String(s.id) === String(clientId)); + if (found) setStudent(found); + }).catch(() => {}).finally(() => setLoading(false)); + }, [clientId]); + + const user = student?.user || {}; + const displayName = `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email || 'Студент'; + const avatar = avatarSrc(user.avatar || user.avatar_url); + + // Счётчик уроков без обратной связи для бейджа + const [pendingCount, setPendingCount] = useState(0); + useEffect(() => { + getStudentLessons(clientId, { status: 'completed', page_size: 100 }) + .then((res) => setPendingCount(res.results.filter((l) => !l.mentor_notes && l.mentor_grade == null).length)) + .catch(() => {}); + }, [clientId]); + + if (loading) { + return ( + + + + ); + } + + return ( + + + + {/* Карточка студента */} + + + + {initials(user)} + + + {displayName} + {user.email && {user.email}} + {student?.subject && ( + + )} + + + + + + {/* Табы */} + + setTab(v)} + sx={{ px: 2, borderBottom: '1px solid', borderColor: 'divider' }} + > + } iconPosition="start" /> + } iconPosition="start" /> + + Обратная связь + {pendingCount > 0 && ( + + )} +
+ } + icon={} + iconPosition="start" + /> + + + + {tab === 'progress' && } + {tab === 'lessons' && } + {tab === 'feedback' && } + + + + ); +} diff --git a/front_minimal/src/sections/students/view/students-view.jsx b/front_minimal/src/sections/students/view/students-view.jsx index b2c1d1f..62bc8c0 100644 --- a/front_minimal/src/sections/students/view/students-view.jsx +++ b/front_minimal/src/sections/students/view/students-view.jsx @@ -29,7 +29,9 @@ import ListItemAvatar from '@mui/material/ListItemAvatar'; import CircularProgress from '@mui/material/CircularProgress'; import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; +import { resolveMediaUrl } from 'src/utils/axios'; import { getStudents, getMyMentors, @@ -43,7 +45,6 @@ import { getMentorshipRequestsPending, } from 'src/utils/students-api'; -import { CONFIG } from 'src/config-global'; import { DashboardContent } from 'src/layouts/dashboard'; import { Iconify } from 'src/components/iconify'; @@ -53,12 +54,7 @@ import { useAuthContext } from 'src/auth/hooks'; // ---------------------------------------------------------------------- -function avatarUrl(href) { - if (!href) return null; - if (href.startsWith('http://') || href.startsWith('https://')) return href; - const base = CONFIG.site.serverUrl?.replace('/api', '') || ''; - return base + (href.startsWith('/') ? href : `/${href}`); -} +const avatarUrl = (href) => resolveMediaUrl(href) || null; function initials(firstName, lastName) { return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase(); @@ -68,6 +64,7 @@ function initials(firstName, lastName) { // MENTOR VIEWS function MentorStudentList({ onRefresh }) { + const router = useRouter(); const [students, setStudents] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); @@ -129,11 +126,12 @@ function MentorStudentList({ onRefresh }) { return ( router.push(paths.dashboard.studentDetail(s.id))} sx={{ p: 3, textAlign: 'center', height: '100%', - cursor: 'default', + cursor: 'pointer', transition: 'box-shadow 0.2s, transform 0.2s', '&:hover': { transform: 'translateY(-2px)', diff --git a/front_minimal/src/utils/groups-api.js b/front_minimal/src/utils/groups-api.js new file mode 100644 index 0000000..b7a98cb --- /dev/null +++ b/front_minimal/src/utils/groups-api.js @@ -0,0 +1,72 @@ +import axios from 'src/utils/axios'; +import { createGroupChat } from 'src/utils/chat-api'; + +// Синхронизирует групповой чат в фоне — ошибки не блокируют основной поток +async function _syncGroupChat(groupId) { + try { + await createGroupChat(groupId); + } catch { + // игнорируем — бэкенд создаёт чат через сигнал, это резервный вызов + } +} + +// ---------------------------------------------------------------------- + +export async function getGroups() { + const res = await axios.get('/groups/'); + const { data } = res; + if (Array.isArray(data)) return data; + return data?.results ?? []; +} + +export async function getGroupById(id) { + const res = await axios.get(`/groups/${id}/`); + return res.data; +} + +export async function createGroup(data) { + const res = await axios.post('/groups/', data); + const group = res.data; + if (group?.id) _syncGroupChat(group.id); + return group; +} + +export async function updateGroup(id, data) { + const res = await axios.patch(`/groups/${id}/`, data); + const group = res.data; + if (group?.id) _syncGroupChat(group.id); + return group; +} + +export async function deleteGroup(id) { + await axios.delete(`/groups/${id}/`); +} + +export async function getGroupLessons(groupId, params = {}) { + const q = new URLSearchParams({ group_id: String(groupId), page_size: '200', ...params }); + const res = await axios.get(`/schedule/lessons/?${q.toString()}`); + const { data } = res; + if (Array.isArray(data)) return data; + return data?.results ?? []; +} + +export async function getGroupHomework(groupId) { + const res = await axios.get(`/homework/homeworks/?page_size=200`); + const { data } = res; + const all = Array.isArray(data) ? data : (data?.results ?? []); + // Фильтруем по урокам группы на фронте (пока нет прямого эндпоинта) + return all; +} + +export async function addStudentToGroup(groupId, studentId) { + const group = await getGroupById(groupId); + const currentIds = (group.students || []).map((s) => s.id); + if (currentIds.includes(studentId)) return group; + return updateGroup(groupId, { students_ids: [...currentIds, studentId] }); +} + +export async function removeStudentFromGroup(groupId, studentId) { + const group = await getGroupById(groupId); + const newIds = (group.students || []).map((s) => s.id).filter((id) => id !== studentId); + return updateGroup(groupId, { students_ids: newIds }); +} diff --git a/front_minimal/src/utils/student-progress-api.js b/front_minimal/src/utils/student-progress-api.js new file mode 100644 index 0000000..5d14bb2 --- /dev/null +++ b/front_minimal/src/utils/student-progress-api.js @@ -0,0 +1,30 @@ +import axios from 'src/utils/axios'; + +// GET /api/student-progress/{clientId}/progress/ +// params: { subject, start_date, end_date } +export async function getStudentProgress(clientId, params) { + const res = await axios.get(`/student-progress/${clientId}/progress/`, { params }); + const { data } = res; + if (data && typeof data === 'object' && 'data' in data) return data.data; + return data; +} + +// GET /api/schedule/lessons/?client_id=X&status=completed +export async function getStudentLessons(clientId, params) { + const res = await axios.get('/schedule/lessons/', { params: { client_id: clientId, ...params } }); + const { data } = res; + const raw = Array.isArray(data) ? data : (data?.results ?? []); + return { + results: raw, + count: Array.isArray(data) ? raw.length : (data?.count ?? raw.length), + next: data?.next ?? null, + }; +} + +// POST /api/schedule/lessons/{id}/complete/ +export async function completeLesson(lessonId, payload) { + const res = await axios.post(`/schedule/lessons/${lessonId}/complete/`, payload); + const { data } = res; + if (data && typeof data === 'object' && 'data' in data) return data.data; + return data; +}