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}
+
+
+
+
+
+
+
+
+ >
+ );
+}
+
// ----------------------------------------------------------------------
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 (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+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 (
+
+ );
}
// ----------------------------------------------------------------------
@@ -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() {
Нет привязанных детей
+ }
+ onClick={() => setAddOpen(true)}
+ >
+ Добавить ребёнка по коду
+
) : (
@@ -106,18 +214,31 @@ export function ChildrenView() {
}
- onClick={() => router.push(`${paths.dashboard.childrenProgress}?child=${child.id}`)}
+ startIcon={}
+ onClick={() => {
+ localStorage.setItem('selected_child', JSON.stringify({ id: child.id, name }));
+ window.dispatchEvent(new Event('child-changed'));
+ router.push(paths.dashboard.root);
+ }}
>
- Прогресс
+ Дашборд
-
+
);
})}
)}
+
+ 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 && (
+ }
+ onClick={() => setAddOpen(true)}
+ >
+ Добавить
+
+ )}
+
+
+ {group.students?.length === 0 && (
+
+
+ Нет участников
+
+ )}
+
+
+ {(group.students || []).map((s) => (
+ handleRemove(s.id)}>
+
+
+ ) : undefined}
+ >
+
+
+ {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}
+ )}
+
+
+ }
+ disabled={sharing === m.id || isShared}
+ onClick={() => handleShare(m.id)}
+ sx={{ flexShrink: 0 }}
+ >
+ {/* eslint-disable-next-line no-nested-ternary */}
+ {isShared ? 'Отправлено' : sharing === m.id ? '...' : 'Поделиться'}
+
+
+
+ );
+ })}
+
+ );
+}
+
+// ----------------------------------------------------------------------
+// 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 (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+// 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 && (
+ }
+ onClick={() => setEditOpen(true)}
+ >
+ Редактировать
+
+ )}
+ }
+ onClick={handleOpenBoard}
+ disabled={boardLoading}
+ >
+ {boardLoading ? 'Загрузка...' : 'Открыть доску'}
+
+
+
+
+
+ {/* Статистика */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Аватары участников (превью) */}
+ {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 (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+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 (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+
+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 (
+
+
+ Группы
+ }
+ onClick={handleOpenCreate}
+ >
+ Создать группу
+
+
+
+ {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 */}
+
+
+ );
+}
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 && }
+
+
+ }
+ >
+ Отправить
+
+ }
+ onClick={() => fileInputRef.current?.click()}
+ disabled={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 || 'Задание не найдено'}
+ }
+ onClick={() => router.push(paths.dashboard.homework)}
+ >
+ К домашним заданиям
+
+
+ );
+ }
+
+ 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 && (
+ }
+ sx={{ mt: 2 }}
+ >
+ Внешняя ссылка
+
+ )}
+
+
+ )}
+
+ {/* 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 && (
+ }
+ onClick={() => setEditing(true)}
+ >
+ Изменить
+
+ )}
+
+
+ {/* 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 || 'Занятие не найдено'}
+ }
+ onClick={() => router.push(paths.dashboard.calendar)}
+ >
+ К расписанию
+
+
+ );
+ }
+
+ 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 && (
+ }
+ onClick={() => setCancelDialogOpen(true)}
+ >
+ Отменить
+
+ )}
+
+ {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 && (
+ }
+ onClick={() => { setCreateHwDesc(''); setCreateHwDeadline(''); setCreateHwFiles([]); setCreateHwError(null); setCreateHwOpen(true); }}
+ >
+ Создать ДЗ
+
+ )}
+ {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 */}
+
+
+ {/* Cancel confirmation dialog */}
+
+
+
+ );
+}
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 */}
+
+
+
+ {!camEnabled && (
+
+ varAlpha(t.vars.palette.grey['500Channel'], 0.16),
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ }}
+ >
+
+
+
+ Камера отключена
+
+
+ )}
+
+ {camEnabled && (
+
+ )}
+
+
+ {/* 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 (
+
+ );
+}
+
+// ----------------------------------------------------------------------
+// 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' && (
+ }
+ onClick={() => setFeedbackLesson(lesson)}
+ >
+ {needsFeedback ? 'Оставить отзыв' : 'Редактировать'}
+
+ )}
+
+
+ );
+ })}
+
+
+
+
+ )}
+
+ 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)}
+
+ }
+ onClick={() => setFeedbackLesson(lesson)}
+ >
+ Оставить отзыв
+
+
+
+ ))}
+
+ 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 && (
+
+ )}
+
+ }
+ onClick={() => router.push(paths.dashboard.students)}
+ >
+ Назад
+
+
+
+
+ {/* Табы */}
+
+ 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;
+}