feat: role-based UI for student/parent, child selector, timezone fix

- Role-based nav: hide payment/referrals for students, board/progress-children for parents
- Add "Мои группы" to student nav; groups detail page read-only for non-mentors
- Logout button in sidebar nav (signOut + redirect to login)
- Parent: auto-select first child, ChildSelector in nav above profile
- Children fetched from /parent/dashboard/ (not the broken /users/parents/children/)
- Add child by 8-char universal code with role validation (client only)
- Removed "Прогресс" button from child cards in children-view
- Fix redirect on child switch — stay on current page (no router.push)
- Parent child notification settings in profile (per-child + per-type toggles)
- Fix scroll on all pages: Main overflow auto, hasSidebar Box overflow auto
- Fix 403: isMentor guard before getStudents() calls in groups pages
- Fix timezone: FullCalendar timeZone prop + fTime() use user.timezone

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev Server 2026-03-12 16:45:15 +03:00
parent 7cf7a78326
commit f6caa7df6b
28 changed files with 6452 additions and 179 deletions

View File

@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useState, useEffect } from 'react';
import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns'; import { format, startOfMonth, endOfMonth, addMonths, subMonths } from 'date-fns';
import useSWR, { mutate } from 'swr'; import useSWR, { mutate } from 'swr';
@ -10,11 +10,14 @@ import {
updateCalendarLesson, updateCalendarLesson,
deleteCalendarLesson, deleteCalendarLesson,
} from 'src/utils/dashboard-api'; } 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 STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
const SUBJECTS_ENDPOINT = '/schedule/subjects/'; const SUBJECTS_ENDPOINT = '/schedule/subjects/';
const GROUPS_ENDPOINT = '/groups/';
const swrOptions = { const swrOptions = {
revalidateIfStale: true, revalidateIfStale: true,
@ -36,9 +39,25 @@ export function useGetEvents(currentDate) {
const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd'); const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
const end = format(endOfMonth(addMonths(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( const { data: response, isLoading, error, isValidating } = useSWR(
['calendar', start, end], ['calendar', start, end, childId],
([, s, e]) => getCalendarLessons(s, e), ([, s, e, cid]) => getCalendarLessons(s, e, cid ? { child_id: cid } : undefined),
swrOptions swrOptions
); );
@ -58,8 +77,10 @@ export function useGetEvents(currentDate) {
: ''; : '';
const subject = lesson.subject_name || lesson.subject || 'Урок'; const subject = lesson.subject_name || lesson.subject || 'Урок';
const student = lesson.client_name || ''; const participant = lesson.group_name
const displayTitle = `${startTimeStr} ${subject}${student ? ` - ${student}` : ''}`; ? `Группа: ${lesson.group_name}`
: (lesson.client_name || '');
const displayTitle = `${startTimeStr} ${subject}${participant ? ` - ${participant}` : ''}`;
const status = String(lesson.status || 'scheduled').toLowerCase(); const status = String(lesson.status || 'scheduled').toLowerCase();
let eventColor = '#7635dc'; let eventColor = '#7635dc';
@ -82,6 +103,8 @@ export function useGetEvents(currentDate) {
status, status,
student: lesson.client_name || '', student: lesson.client_name || '',
mentor: lesson.mentor_name || '', mentor: lesson.mentor_name || '',
group: lesson.group || null,
group_name: lesson.group_name || '',
}, },
}; };
}); });
@ -134,6 +157,16 @@ export function useGetSubjects() {
}, [response, isLoading, error]); }, [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) { function revalidateCalendar(date) {
@ -144,18 +177,16 @@ function revalidateCalendar(date) {
} }
export async function createEvent(eventData, currentDate) { export async function createEvent(eventData, currentDate) {
const startTime = new Date(eventData.start_time); const isGroup = !!eventData.group;
const endTime = new Date(startTime.getTime() + (eventData.duration || 60) * 60000);
const payload = { const payload = {
client: String(eventData.client),
title: eventData.title || 'Занятие', title: eventData.title || 'Занятие',
description: eventData.description || '', description: eventData.description || '',
start_time: startTime.toISOString(), start_time: eventData.start_time,
end_time: endTime.toISOString(), duration: eventData.duration || 60,
price: eventData.price, price: eventData.price,
is_recurring: eventData.is_recurring || false, is_recurring: eventData.is_recurring || false,
...(eventData.subject && { subject_id: Number(eventData.subject) }), ...(eventData.subject && { subject_id: Number(eventData.subject) }),
...(isGroup ? { group: eventData.group } : { client: String(eventData.client) }),
}; };
const res = await createCalendarLesson(payload); const res = await createCalendarLesson(payload);
@ -167,12 +198,8 @@ export async function updateEvent(eventData, currentDate) {
const { id, ...data } = eventData; const { id, ...data } = eventData;
const updatePayload = {}; const updatePayload = {};
if (data.start_time) { if (data.start_time) updatePayload.start_time = data.start_time;
const startTime = new Date(data.start_time); if (data.duration) updatePayload.duration = data.duration;
const endTime = new Date(startTime.getTime() + (data.duration || 60) * 60000);
updatePayload.start_time = startTime.toISOString();
updatePayload.end_time = endTime.toISOString();
}
if (data.price != null) updatePayload.price = data.price; if (data.price != null) updatePayload.price = data.price;
if (data.description != null) updatePayload.description = data.description; if (data.description != null) updatePayload.description = data.description;
if (data.status) updatePayload.status = data.status; if (data.status) updatePayload.status = data.status;

View File

@ -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 <LessonDetailView id={params.id} />;
}
// ----------------------------------------------------------------------
const dynamic = CONFIG.isStaticExport ? 'auto' : 'force-dynamic';
export { dynamic };
export async function generateStaticParams() {
return [];
}

View File

@ -1,4 +1,3 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
@ -6,29 +5,82 @@ import Typography from '@mui/material/Typography';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import { useAuthContext } from 'src/auth/hooks'; import { useAuthContext } from 'src/auth/hooks';
import axios from 'src/utils/axios';
import { OverviewCourseView } from 'src/sections/overview/course/view'; import { OverviewCourseView } from 'src/sections/overview/course/view';
import { OverviewClientView } from 'src/sections/overview/client/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() { export default function DashboardPage() {
const { user, loading } = useAuthContext(); const { user, loading } = useAuthContext();
// Для родителя: выбранный ребёнок из localStorage
const [selectedChild, setSelectedChild] = useState(null); const [selectedChild, setSelectedChild] = useState(null);
const [childrenLoading, setChildrenLoading] = useState(false);
const [noChildren, setNoChildren] = useState(false);
// Load children for parent role
useEffect(() => { 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 { try {
const saved = localStorage.getItem('selected_child'); const saved = localStorage.getItem('selected_child');
if (saved) setSelectedChild(JSON.parse(saved)); if (saved) setSelectedChild(JSON.parse(saved));
} catch { } catch { /* ignore */ }
// ignore };
} window.addEventListener('child-changed', handler);
} return () => window.removeEventListener('child-changed', handler);
}, [user]); }, [user]);
// ----------------------------------------------------------------------
if (loading) { if (loading) {
return ( return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}> <Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
@ -39,26 +91,40 @@ export default function DashboardPage() {
if (!user) return null; if (!user) return null;
if (user.role === 'mentor') { if (user.role === 'mentor') return <OverviewCourseView />;
return <OverviewCourseView />;
}
if (user.role === 'client') { if (user.role === 'client') return <OverviewClientView />;
return <OverviewClientView />;
}
if (user.role === 'parent') { if (user.role === 'parent') {
if (childrenLoading) {
return ( return (
<OverviewClientView <Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
childId={selectedChild?.id || null} <CircularProgress />
childName={selectedChild?.name || null}
/>
);
}
return (
<Box sx={{ p: 3, textAlign: 'center' }}>
<Typography color="text.secondary">Неизвестная роль: {user.role}</Typography>
</Box> </Box>
); );
} }
if (noChildren) {
return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 1 }}>
<Typography variant="h6" color="text.secondary">Нет привязанных детей</Typography>
<Typography variant="body2" color="text.disabled">
Обратитесь к администратору для привязки аккаунта ребёнка
</Typography>
</Box>
);
}
if (!selectedChild) {
return (
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
<CircularProgress />
</Box>
);
}
return <OverviewClientView childId={selectedChild.id} childName={selectedChild.name} />;
}
return null;
}

View File

@ -1,16 +1,26 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Menu from '@mui/material/Menu';
import Stack from '@mui/material/Stack'; import Stack from '@mui/material/Stack';
import Avatar from '@mui/material/Avatar'; 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 Tooltip from '@mui/material/Tooltip';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import LinearProgress from '@mui/material/LinearProgress'; 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 { CONFIG } from 'src/config-global';
import { useAuthContext } from 'src/auth/hooks'; 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 { 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 (
<>
<Box>
<Typography
variant="caption"
sx={{ color: 'var(--layout-nav-text-disabled-color)', px: 0.5, mb: 0.5, display: 'block' }}
>
Ученик
</Typography>
<Stack
direction="row"
alignItems="center"
spacing={1.5}
onClick={(e) => 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' },
}}
>
<Avatar
sx={{ width: 32, height: 32, fontSize: 13, bgcolor: 'secondary.main', flexShrink: 0 }}
>
{(selected.name[0] || '?').toUpperCase()}
</Avatar>
<Typography
variant="subtitle2"
noWrap
sx={{ flex: 1, color: 'var(--layout-nav-text-primary-color)' }}
>
{selected.name}
</Typography>
<Iconify
icon="solar:alt-arrow-down-bold"
width={16}
sx={{ color: 'var(--layout-nav-text-disabled-color)', flexShrink: 0 }}
/>
</Stack>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
anchorOrigin={{ vertical: 'top', horizontal: 'left' }}
transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}
slotProps={{ paper: { sx: { minWidth: 200 } } }}
>
{children.map((child) => {
const name =
`${child.first_name || ''} ${child.last_name || ''}`.trim() || child.email;
const isSelected = selected.id === child.id;
return (
<MenuItem
key={child.id}
selected={isSelected}
onClick={() => handleSelect(child)}
sx={{ gap: 1.5 }}
>
<Avatar sx={{ width: 28, height: 28, fontSize: 12, bgcolor: 'secondary.main' }}>
{name[0]?.toUpperCase()}
</Avatar>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="body2" noWrap>
{name}
</Typography>
{child.email && (
<Typography variant="caption" color="text.secondary" noWrap>
{child.email}
</Typography>
)}
</Box>
{isSelected && (
<Iconify
icon="solar:check-circle-bold"
width={16}
sx={{ color: 'primary.main', ml: 'auto', flexShrink: 0 }}
/>
)}
</MenuItem>
);
})}
</Menu>
</Box>
<Divider />
</>
);
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function NavUpgrade({ sx, ...other }) { export function NavUpgrade({ sx, ...other }) {
const { user } = useAuthContext(); const { user, checkUserSession } = useAuthContext();
const [sub, setSub] = useState(undefined); // undefined = loading, null = no sub 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(() => { useEffect(() => {
if (!user?.id) return; if (!user?.id) return;
fetchActiveSubscription().then(setSub); if (user.role !== 'client') fetchActiveSubscription().then(setSub);
}, [user?.id]); if (user.role === 'parent') fetchChildren().then(setChildren);
}, [user?.id, user?.role]);
const displayName = user const displayName = user
? `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email ? `${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}` : `${(CONFIG.site.serverUrl || '').replace(/\/api\/?$/, '')}${user.avatar}`
: null; : null;
// Subscription label
let labelColor = 'default'; let labelColor = 'default';
let labelText = 'Нет подписки'; let labelText = 'Нет подписки';
if (sub === undefined) { if (sub === undefined) {
labelText = '…'; labelText = '…';
} else if (sub && sub.is_active_now) { } else if (sub && sub.is_active_now) {
const planName = sub.plan?.name || 'Подписка'; const planName = sub.plan?.name || 'Подписка';
const status = sub.status; labelText = sub.status === 'trial' ? `Пробный: ${planName}` : planName;
labelText = status === 'trial' ? `Пробный: ${planName}` : planName; labelColor = sub.status === 'trial' ? 'warning' : 'success';
labelColor = status === 'trial' ? 'warning' : 'success';
} }
// End date display
let endDateText = null; let endDateText = null;
if (sub && sub.is_active_now) { if (sub && sub.is_active_now) {
const endField = sub.status === 'trial' ? sub.trial_end_date : sub.end_date; const endField = sub.status === 'trial' ? sub.trial_end_date : sub.end_date;
if (endField) { if (endField) {
const date = new Date(endField); endDateText = `до ${new Date(endField).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })}`;
endDateText = `до ${date.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} дн.`; endDateText = endDateText ? `${endDateText} (${sub.days_left} дн.)` : `${sub.days_left} дн.`;
} }
} }
// Progress bar for days left (out of 30)
const daysProgress = const daysProgress =
sub?.days_left != null && sub?.is_active_now sub?.days_left != null && sub?.is_active_now
? Math.min(100, Math.round((sub.days_left / 30) * 100)) ? Math.min(100, Math.round((sub.days_left / 30) * 100))
@ -94,12 +256,18 @@ export function NavUpgrade({ sx, ...other }) {
py: 2.5, py: 2.5,
borderTop: '1px solid', borderTop: '1px solid',
borderColor: 'var(--layout-nav-border-color)', borderColor: 'var(--layout-nav-border-color)',
gap: 1.5,
...sx, ...sx,
}} }}
{...other} {...other}
> >
{/* Селектор ребёнка — только для родителя, над профилем */}
{user?.role === 'parent' && children.length > 0 && (
<ChildSelector children={children} />
)}
{/* Avatar + name + email */} {/* Avatar + name + email */}
<Stack direction="row" alignItems="center" spacing={1.5} sx={{ mb: 1.5 }}> <Stack direction="row" alignItems="center" spacing={1.5}>
<Box sx={{ position: 'relative', flexShrink: 0 }}> <Box sx={{ position: 'relative', flexShrink: 0 }}>
<Avatar <Avatar
src={avatarSrc} src={avatarSrc}
@ -128,7 +296,8 @@ export function NavUpgrade({ sx, ...other }) {
</Box> </Box>
</Stack> </Stack>
{/* Subscription badge */} {/* Subscription badge — только для ментора и родителя */}
{user?.role !== 'client' && (
<Stack spacing={0.75}> <Stack spacing={0.75}>
<Stack direction="row" alignItems="center" justifyContent="space-between"> <Stack direction="row" alignItems="center" justifyContent="space-between">
<Label color={labelColor} variant="soft" sx={{ fontSize: 11 }}> <Label color={labelColor} variant="soft" sx={{ fontSize: 11 }}>
@ -144,7 +313,6 @@ export function NavUpgrade({ sx, ...other }) {
</Typography> </Typography>
)} )}
</Stack> </Stack>
{daysProgress !== null && ( {daysProgress !== null && (
<Tooltip title={`Осталось ${sub.days_left} дн.`} placement="top"> <Tooltip title={`Осталось ${sub.days_left} дн.`} placement="top">
<LinearProgress <LinearProgress
@ -156,12 +324,24 @@ export function NavUpgrade({ sx, ...other }) {
</Tooltip> </Tooltip>
)} )}
</Stack> </Stack>
)}
<Button
fullWidth
size="small"
color="error"
variant="soft"
onClick={handleLogout}
startIcon={<Iconify icon="solar:logout-2-bold-duotone" width={18} />}
>
Выйти
</Button>
</Stack> </Stack>
); );
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// UpgradeBlock оставляем для совместимости, больше не используется в платформе
export function UpgradeBlock({ sx, ...other }) { export function UpgradeBlock({ sx, ...other }) {
return <Box sx={sx} {...other} />; return <Box sx={sx} {...other} />;
} }

View File

@ -7,15 +7,20 @@ import { SvgColor } from 'src/components/svg-color';
const icon = (name) => <SvgColor src={`${CONFIG.site.basePath}/assets/icons/navbar/${name}.svg`} />; const icon = (name) => <SvgColor src={`${CONFIG.site.basePath}/assets/icons/navbar/${name}.svg`} />;
const ICONS = { const ICONS = {
chat: icon('ic-chat'),
user: icon('ic-user'),
course: icon('ic-course'),
calendar: icon('ic-calendar'),
dashboard: icon('ic-dashboard'), dashboard: icon('ic-dashboard'),
kanban: icon('ic-kanban'), user: icon('ic-user'),
calendar: icon('ic-calendar'),
booking: icon('ic-booking'),
folder: icon('ic-folder'), folder: icon('ic-folder'),
kanban: icon('ic-kanban'),
chat: icon('ic-chat'),
mail: icon('ic-mail'),
analytics: icon('ic-analytics'), analytics: icon('ic-analytics'),
course: icon('ic-course'),
label: icon('ic-label'), 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: 'Инструменты', subheader: 'Инструменты',
items: [ items: [
// Ученики/Менторы для всех ролей (разный контент внутри) // Ученики/Менторы только для ментора и клиента (не для родителя)
...(!isParent ? [
{ title: isMentor ? 'Ученики' : 'Менторы', path: paths.dashboard.students, icon: ICONS.user }, { title: isMentor ? 'Ученики' : 'Менторы', path: paths.dashboard.students, icon: ICONS.user },
] : []),
{ title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar }, { 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.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.chatPlatform, icon: ICONS.chat },
{ title: 'Уведомления', path: paths.dashboard.notifications, icon: ICONS.label }, { title: 'Уведомления', path: paths.dashboard.notifications, icon: ICONS.mail },
// Ментор-специфичные // Ментор-специфичные
...(isMentor ? [ ...(isMentor ? [
{ title: 'Группы', path: paths.dashboard.groups, icon: ICONS.tour },
{ title: 'Аналитика', path: paths.dashboard.analytics, icon: ICONS.analytics }, { 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 }, { title: 'Мой прогресс', path: paths.dashboard.myProgress, icon: ICONS.course },
] : []), ] : []),
// Родитель
...(isParent ? [
{ title: 'Прогресс', path: paths.dashboard.myProgress, icon: ICONS.course },
] : []),
// Родитель-специфичные // Родитель-специфичные
...(isParent ? [ ...(isParent ? [
{ title: 'Дети', path: paths.dashboard.children, icon: ICONS.user }, { 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: 'Аккаунт', subheader: 'Аккаунт',
items: [ items: [
{ title: 'Профиль', path: paths.dashboard.profile, icon: ICONS.user }, { 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 },
] : []),
], ],
}, },
]; ];

View File

@ -34,7 +34,7 @@ export function LayoutSection({
<> <>
{inputGlobalStyles} {inputGlobalStyles}
<Box id="root__layout" className={layoutClasses.root} sx={sx}> <Box id="root__layout" className={layoutClasses.root} sx={{ display: 'flex', flexDirection: 'row', height: '100vh', overflow: 'hidden', ...sx }}>
{sidebarSection ? ( {sidebarSection ? (
<> <>
{sidebarSection} {sidebarSection}
@ -42,6 +42,8 @@ export function LayoutSection({
display="flex" display="flex"
flex="1 1 auto" flex="1 1 auto"
flexDirection="column" flexDirection="column"
minHeight={0}
overflow="auto"
className={layoutClasses.hasSidebar} className={layoutClasses.hasSidebar}
> >
{headerSection} {headerSection}

View File

@ -161,12 +161,13 @@ export function DashboardLayout({ sx, children, data }) {
isNavMini={isNavMini} isNavMini={isNavMini}
layoutQuery={layoutQuery} layoutQuery={layoutQuery}
cssVars={navColorVars.section} cssVars={navColorVars.section}
onToggleNav={() => onToggleNav={() => {
settings.onUpdateField( settings.onUpdateField(
'navLayout', 'navLayout',
settings.navLayout === 'vertical' ? 'mini' : 'vertical' settings.navLayout === 'vertical' ? 'mini' : 'vertical'
) );
} setTimeout(() => window.dispatchEvent(new Event('resize')), 0);
}}
/> />
) )
} }

View File

@ -16,6 +16,8 @@ export function Main({ children, isNavHorizontal, sx, ...other }) {
display: 'flex', display: 'flex',
flex: '1 1 auto', flex: '1 1 auto',
flexDirection: 'column', flexDirection: 'column',
minHeight: 0,
overflow: 'auto',
...(isNavHorizontal && { ...(isNavHorizontal && {
'--layout-dashboard-content-pt': '40px', '--layout-dashboard-content-pt': '40px',
}), }),

View File

@ -25,8 +25,10 @@ export const paths = {
root: ROOTS.DASHBOARD, root: ROOTS.DASHBOARD,
calendar: `${ROOTS.DASHBOARD}/schedule`, calendar: `${ROOTS.DASHBOARD}/schedule`,
homework: `${ROOTS.DASHBOARD}/homework`, homework: `${ROOTS.DASHBOARD}/homework`,
homeworkDetail: (id) => `${ROOTS.DASHBOARD}/homework/${id}`,
materials: `${ROOTS.DASHBOARD}/materials`, materials: `${ROOTS.DASHBOARD}/materials`,
students: `${ROOTS.DASHBOARD}/students`, students: `${ROOTS.DASHBOARD}/students`,
studentDetail: (id) => `${ROOTS.DASHBOARD}/students/${id}`,
notifications: `${ROOTS.DASHBOARD}/notifications`, notifications: `${ROOTS.DASHBOARD}/notifications`,
board: `${ROOTS.DASHBOARD}/board`, board: `${ROOTS.DASHBOARD}/board`,
chatPlatform: `${ROOTS.DASHBOARD}/chat-platform`, chatPlatform: `${ROOTS.DASHBOARD}/chat-platform`,
@ -38,5 +40,9 @@ export const paths = {
children: `${ROOTS.DASHBOARD}/children`, children: `${ROOTS.DASHBOARD}/children`,
childrenProgress: `${ROOTS.DASHBOARD}/children-progress`, childrenProgress: `${ROOTS.DASHBOARD}/children-progress`,
myProgress: `${ROOTS.DASHBOARD}/my-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}`,
}, },
}; };

View File

@ -1,5 +1,5 @@
import { lazy, Suspense } from 'react'; 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 { AuthGuard } from 'src/auth/guard/auth-guard';
import { GuestGuard } from 'src/auth/guard/guest-guard'; import { GuestGuard } from 'src/auth/guard/guest-guard';
@ -52,6 +52,9 @@ const MaterialsView = lazy(() =>
const StudentsView = lazy(() => const StudentsView = lazy(() =>
import('src/sections/students/view').then((m) => ({ default: m.StudentsView })) 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(() => const NotificationsView = lazy(() =>
import('src/sections/notifications/view').then((m) => ({ default: m.NotificationsView })) import('src/sections/notifications/view').then((m) => ({ default: m.NotificationsView }))
); );
@ -85,6 +88,18 @@ const ChildrenProgressView = lazy(() =>
const MyProgressView = lazy(() => const MyProgressView = lazy(() =>
import('src/sections/my-progress/view').then((m) => ({ default: m.MyProgressView })) 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) // Video call (fullscreen, no dashboard layout)
@ -93,6 +108,10 @@ const VideoCallView = lazy(() =>
import('src/sections/video-call/view').then((m) => ({ default: m.VideoCallView })) 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 // Error pages
@ -126,6 +145,16 @@ function AuthLayoutWrapper() {
); );
} }
function LessonDetailWrapper() {
const { id } = useParams();
return <LessonDetailView id={id} />;
}
function HomeworkDetailWrapper() {
const { id } = useParams();
return <HomeworkDetailView id={id} />;
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
export function Router() { export function Router() {
@ -166,6 +195,7 @@ export function Router() {
{ path: 'homework', element: <S><HomeworkView /></S> }, { path: 'homework', element: <S><HomeworkView /></S> },
{ path: 'materials', element: <S><MaterialsView /></S> }, { path: 'materials', element: <S><MaterialsView /></S> },
{ path: 'students', element: <S><StudentsView /></S> }, { path: 'students', element: <S><StudentsView /></S> },
{ path: 'students/:clientId', element: <S><StudentDetailView /></S> },
{ path: 'notifications', element: <S><NotificationsView /></S> }, { path: 'notifications', element: <S><NotificationsView /></S> },
{ path: 'board', element: <S><BoardView /></S> }, { path: 'board', element: <S><BoardView /></S> },
{ path: 'chat-platform', element: <S><ChatPlatformView /></S> }, { path: 'chat-platform', element: <S><ChatPlatformView /></S> },
@ -177,6 +207,11 @@ export function Router() {
{ path: 'children', element: <S><ChildrenView /></S> }, { path: 'children', element: <S><ChildrenView /></S> },
{ path: 'children-progress', element: <S><ChildrenProgressView /></S> }, { path: 'children-progress', element: <S><ChildrenProgressView /></S> },
{ path: 'my-progress', element: <S><MyProgressView /></S> }, { path: 'my-progress', element: <S><MyProgressView /></S> },
{ path: 'prejoin', element: <S><PrejoinView /></S> },
{ path: 'lesson/:id', element: <S><LessonDetailWrapper /></S> },
{ path: 'homework/:id', element: <S><HomeworkDetailWrapper /></S> },
{ path: 'groups', element: <S><GroupsView /></S> },
{ path: 'groups/:id', element: <S><GroupDetailView /></S> },
], ],
}, },

View File

@ -34,6 +34,7 @@ import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';
import axios, { resolveMediaUrl } from 'src/utils/axios';
import { unlinkTelegram, getTelegramStatus, getTelegramBotInfo, generateTelegramCode } from 'src/utils/telegram-api'; import { unlinkTelegram, getTelegramStatus, getTelegramBotInfo, generateTelegramCode } from 'src/utils/telegram-api';
import { import {
searchCities, searchCities,
@ -46,7 +47,6 @@ import {
updateNotificationPreferences, updateNotificationPreferences,
} from 'src/utils/profile-api'; } from 'src/utils/profile-api';
import { CONFIG } from 'src/config-global';
import { DashboardContent } from 'src/layouts/dashboard'; import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
@ -87,12 +87,7 @@ const CHANNELS = [
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function avatarSrc(src) { const avatarSrc = (src) => resolveMediaUrl(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}`);
}
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -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 <CircularProgress size={20} sx={{ mt: 1 }} />;
if (children.length === 0) return null;
return (
<Card sx={{ mt: 0 }}>
<CardContent>
<Typography variant="subtitle1" sx={{ mb: 2 }}>Уведомления по детям</Typography>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
<Stack spacing={1}>
{children.map((child) => {
const s = settings[child.id] || { enabled: true, type_settings: {} };
const isExpanded = expanded === child.id;
return (
<Box key={child.id} sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 1.5, overflow: 'hidden' }}>
{/* Header row */}
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ px: 2, py: 1.5, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
onClick={() => setExpanded(isExpanded ? null : child.id)}
>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 32, height: 32, fontSize: 13, bgcolor: 'secondary.main' }}>
{child.name[0]?.toUpperCase()}
</Avatar>
<Box>
<Typography variant="subtitle2">{child.name}</Typography>
<Typography variant="caption" color="text.secondary">
{s.enabled ? 'Включены' : 'Выключены'}
</Typography>
</Box>
</Stack>
<Stack direction="row" alignItems="center" spacing={1} onClick={(e) => e.stopPropagation()}>
{saving[child.id] && <CircularProgress size={14} />}
<Switch
size="small"
checked={!!s.enabled}
onChange={() => patch(child.id, { enabled: !s.enabled })}
disabled={saving[child.id]}
/>
<Iconify
icon={isExpanded ? 'solar:alt-arrow-up-bold' : 'solar:alt-arrow-down-bold'}
width={16}
sx={{ color: 'text.disabled', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setExpanded(isExpanded ? null : child.id); }}
/>
</Stack>
</Stack>
{/* Expanded type list */}
{isExpanded && (
<Box sx={{ px: 2, pb: 2, borderTop: '1px solid', borderColor: 'divider' }}>
<Stack spacing={0.5} sx={{ mt: 1 }}>
{CHILD_NOTIFICATION_TYPES.map((type) => {
const isOn = s.type_settings[type.value] !== false;
return (
<Stack key={type.value} direction="row" alignItems="center" justifyContent="space-between" sx={{ py: 0.5, opacity: s.enabled ? 1 : 0.5 }}>
<Typography variant="body2">{type.label}</Typography>
<Switch
size="small"
checked={isOn}
disabled={saving[child.id] || !s.enabled}
onChange={() => patch(child.id, { type_settings: { ...s.type_settings, [type.value]: !isOn } })}
/>
</Stack>
);
})}
</Stack>
</Box>
)}
</Box>
);
})}
</Stack>
</CardContent>
</Card>
);
}
// ----------------------------------------------------------------------
function NotificationMatrix({ prefs, onChange, role }) { function NotificationMatrix({ prefs, onChange, role }) {
const visibleTypes = role === 'parent' const visibleTypes = role === 'parent'
? NOTIFICATION_TYPES.filter((t) => !PARENT_EXCLUDED_TYPES.includes(t.key)) ? NOTIFICATION_TYPES.filter((t) => !PARENT_EXCLUDED_TYPES.includes(t.key))
@ -395,7 +538,6 @@ export function AccountPlatformView() {
// Profile fields // Profile fields
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [avatarPreview, setAvatarPreview] = useState(null); const [avatarPreview, setAvatarPreview] = useState(null);
const [avatarHovered, setAvatarHovered] = useState(false); const [avatarHovered, setAvatarHovered] = useState(false);
@ -429,7 +571,6 @@ export function AccountPlatformView() {
if (user) { if (user) {
setFirstName(user.first_name || ''); setFirstName(user.first_name || '');
setLastName(user.last_name || ''); setLastName(user.last_name || '');
setPhone(user.phone || '');
setEmail(user.email || ''); setEmail(user.email || '');
} }
}, [user]); }, [user]);
@ -710,13 +851,6 @@ export function AccountPlatformView() {
fullWidth fullWidth
/> />
</Stack> </Stack>
<TextField
label="Телефон"
value={phone}
onChange={(e) => setPhone(e.target.value)}
onBlur={(e) => handleProfileBlur('phone', e.target.value.trim())}
fullWidth
/>
<TextField <TextField
label="Email" label="Email"
value={email} value={email}
@ -909,6 +1043,9 @@ export function AccountPlatformView() {
</CardContent> </CardContent>
</Card> </Card>
{/* Per-child notification settings — parent only */}
{user?.role === 'parent' && <ParentChildNotifications />}
{/* AI homework settings (mentor only) */} {/* AI homework settings (mentor only) */}
{user?.role === 'mentor' && settings && ( {user?.role === 'mentor' && settings && (
<Card> <Card>

View File

@ -7,18 +7,37 @@ import listPlugin from '@fullcalendar/list';
import timelinePlugin from '@fullcalendar/timeline'; import timelinePlugin from '@fullcalendar/timeline';
import ruLocale from '@fullcalendar/core/locales/ru'; 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 Card from '@mui/material/Card';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack'; 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 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 Container from '@mui/material/Container';
import Typography from '@mui/material/Typography'; 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 { paths } from 'src/routes/paths';
import { useRouter } from 'src/routes/hooks';
import { useAuthContext } from 'src/auth/hooks'; import { useAuthContext } from 'src/auth/hooks';
import { useBoolean } from 'src/hooks/use-boolean'; import { useBoolean } from 'src/hooks/use-boolean';
import { useGetEvents, updateEvent, createEvent, deleteEvent } from 'src/actions/calendar'; 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 { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs'; 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 (
<Dialog
open={open}
onClose={(_, reason) => { if (reason !== 'backdropClick') handleClose(); }}
maxWidth="sm"
fullWidth
disableEscapeKeyDown
>
<DialogTitle sx={{ pb: 1 }}>
<Typography variant="h6">Завершение урока</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
Заполните информацию о прошедшем занятии
</Typography>
</DialogTitle>
<DialogContent dividers sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
{error && <Alert severity="error">{error}</Alert>}
{/* Grades */}
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Оценка за занятие</Typography>
<Stack direction="row" spacing={4}>
<FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5 }}>Успеваемость (15)</Typography>
<Rating
value={mentorGrade}
max={5}
onChange={(_, v) => setMentorGrade(v ?? 0)}
disabled={loading}
/>
</FormControl>
<FormControl>
<Typography variant="caption" color="text.secondary" sx={{ mb: 0.5 }}>Школьная оценка (15)</Typography>
<Rating
value={schoolGrade}
max={5}
onChange={(_, v) => setSchoolGrade(v ?? 0)}
disabled={loading}
/>
</FormControl>
</Stack>
</Box>
<Divider />
{/* Comment */}
<TextField
label="Комментарий к уроку"
placeholder="Что прошли, успехи, рекомендации…"
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
rows={3}
disabled={loading}
fullWidth
/>
<Divider />
{/* Homework toggle */}
<Box>
<FormControlLabel
control={
<Switch
checked={hasHw}
onChange={(e) => setHasHw(e.target.checked)}
disabled={loading}
/>
}
label="Выдать домашнее задание"
/>
</Box>
{/* Homework form */}
{hasHw && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Название ДЗ"
value={hwTitle}
onChange={(e) => setHwTitle(e.target.value)}
disabled={loading}
fullWidth
size="small"
/>
<TextField
label="Описание задания"
placeholder="Опишите задание…"
value={hwText}
onChange={(e) => setHwText(e.target.value)}
multiline
rows={3}
disabled={loading}
fullWidth
/>
<TextField
label="Дедлайн"
type="date"
value={hwDeadline}
onChange={(e) => setHwDeadline(e.target.value)}
disabled={loading}
fullWidth
size="small"
InputLabelProps={{ shrink: true }}
inputProps={{ min: new Date().toISOString().slice(0, 10) }}
/>
{/* File upload */}
<Box>
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileChange}
disabled={loading}
style={{ display: 'none' }}
/>
<Button
size="small"
variant="outlined"
startIcon={<Iconify icon="solar:paperclip-bold" width={16} />}
onClick={() => fileInputRef.current?.click()}
disabled={loading || hwFiles.length >= MAX_HW_FILES}
>
Прикрепить файлы
</Button>
{hwFiles.length > 0 && (
<Stack spacing={0.5} sx={{ mt: 1 }}>
{hwFiles.map((f, i) => (
<Stack
key={`${f.name}-${i}-${f.size}`}
direction="row"
alignItems="center"
spacing={1}
sx={{ p: 0.75, borderRadius: 1, bgcolor: 'action.hover' }}
>
<Iconify icon="solar:document-bold" width={16} color="text.secondary" />
<Typography variant="caption" noWrap sx={{ flex: 1 }}>{f.name}</Typography>
<Button
size="small"
color="error"
disabled={loading}
onClick={() => setHwFiles((p) => p.filter((_, j) => j !== i))}
sx={{ minWidth: 0, p: 0.5 }}
>
<Iconify icon="solar:trash-bin-trash-bold" width={14} />
</Button>
</Stack>
))}
</Stack>
)}
</Box>
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={handleClose} disabled={loading} color="inherit">
Пропустить
</Button>
<Button
onClick={handleSubmit}
disabled={loading}
variant="contained"
startIcon={loading ? <CircularProgress size={16} color="inherit" /> : <Iconify icon="solar:check-circle-bold" width={18} />}
>
{loading ? 'Сохранение…' : 'Сохранить'}
</Button>
</DialogActions>
</Dialog>
);
}
// ----------------------------------------------------------------------
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 (
<Box sx={{ mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: 'text.secondary' }}>
Сегодня · {todayUpcoming.length} {todayUpcoming.length === 1 ? 'занятие' : 'занятий'}
</Typography>
<Box sx={{ display: 'grid', gap: 2, gridTemplateColumns: { xs: '1fr', sm: 'repeat(2,1fr)', md: 'repeat(3,1fr)' } }}>
{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 (
<Card
key={ev.id}
onClick={() => !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' } : {},
}}
>
<Avatar sx={{ bgcolor: 'primary.main', width: 44, height: 44, flexShrink: 0 }}>
{initial}
</Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="subtitle2" noWrap>{subject}</Typography>
{personName && (
<Typography variant="caption" color="text.secondary" noWrap display="block">{personName}</Typography>
)}
<Chip
label={fTime(ev.start, timezone)}
size="small"
color={canJoin ? 'primary' : 'default'}
variant={canJoin ? 'filled' : 'outlined'}
icon={<Iconify icon="solar:clock-circle-bold" width={12} />}
sx={{ mt: 0.5, height: 20, fontSize: 11 }}
/>
</Box>
{canJoin && (
<Button
size="small"
variant="contained"
color="primary"
disabled={isJoining}
onClick={(e) => { e.stopPropagation(); handleJoin(ev); }}
startIcon={isJoining
? <CircularProgress size={14} color="inherit" />
: <Iconify icon="solar:videocamera-record-bold" width={16} />}
sx={{ flexShrink: 0, whiteSpace: 'nowrap' }}
>
{isJoining ? '...' : 'Войти'}
</Button>
)}
</Card>
);
})}
</Box>
</Box>
);
}
// ----------------------------------------------------------------------
// Statuses that should open the detail page instead of the edit form
const NON_PLANNED_STATUSES = ['completed', 'in_progress', 'ongoing', 'cancelled'];
export function CalendarView() { export function CalendarView() {
const settings = useSettingsContext(); const settings = useSettingsContext();
const router = useRouter();
const { user } = useAuthContext(); const { user } = useAuthContext();
const isMentor = user?.role === 'mentor'; 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, const { calendarRef, view, date, onDatePrev, onDateNext, onDateToday, onChangeView,
onSelectRange, onClickEvent, onResizeEvent, onDropEvent, onInitialView, onSelectRange, onClickEvent, onResizeEvent, onDropEvent, onInitialView,
openForm, onOpenForm, onCloseForm, selectEventId, selectedRange } = useCalendar(); 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); const { events, eventsLoading } = useGetEvents(date);
useEffect(() => { useEffect(() => {
@ -91,6 +561,8 @@ export function CalendarView() {
)} )}
</Stack> </Stack>
<UpcomingLessonsBar events={events} isMentor={isMentor} timezone={user?.timezone} />
<Card sx={{ position: 'relative' }}> <Card sx={{ position: 'relative' }}>
<StyledCalendar> <StyledCalendar>
<CalendarToolbar <CalendarToolbar
@ -113,18 +585,26 @@ export function CalendarView() {
ref={calendarRef} ref={calendarRef}
initialDate={date} initialDate={date}
initialView={view} initialView={view}
dayMaxEventRows={3} dayMaxEventRows={5}
eventDisplay="block" eventDisplay="block"
headerToolbar={false} headerToolbar={false}
allDayMaintainDuration allDayMaintainDuration
eventResizableFromStart eventResizableFromStart
displayEventTime={false} displayEventTime={false}
select={isMentor ? onSelectRange : undefined} select={isMentor ? onSelectRange : undefined}
eventClick={onClickEvent} eventClick={handleEventClick}
eventDrop={isMentor ? onDropEvent : undefined} eventDrop={isMentor ? onDropEvent : undefined}
eventResize={isMentor ? onResizeEvent : undefined} eventResize={isMentor ? onResizeEvent : undefined}
eventOrder={(a, b) => {
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]} plugins={[dayGridPlugin, timeGridPlugin, interactionPlugin, listPlugin, timelinePlugin]}
locale={ruLocale} locale={ruLocale}
timeZone={user?.timezone || 'local'}
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }} slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
eventTimeFormat={{ 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} onCreateEvent={handleCreateEvent}
onUpdateEvent={handleUpdateEvent} onUpdateEvent={handleUpdateEvent}
onDeleteEvent={handleDeleteEvent} onDeleteEvent={handleDeleteEvent}
onCompleteLesson={(lessonId) => { onCloseForm(); handleOpenCompleteLesson(lessonId); }}
/>
)}
{/* Auto-open after video call redirect (reads sessionStorage) */}
{isMentor && <CompleteLessonDialog />}
{/* Controlled: opened from CalendarForm */}
{isMentor && (
<CompleteLessonDialog
lessonId={completeLessonId}
open={completeLessonOpen}
onClose={() => setCompleteLessonOpen(false)}
/> />
)} )}
</> </>

View File

@ -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 (
<Box sx={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'text.secondary' }}>
<Typography>Выберите чат из списка</Typography>
</Box>
);
}
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 (
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', minHeight: 0, overflow: 'hidden' }}>
{/* Header */}
{!hideHeader && (
<Stack
direction="row"
alignItems="center"
spacing={1.5}
sx={{ px: 2, py: 1.5, borderBottom: '1px solid', borderColor: 'divider', flexShrink: 0 }}
>
{onBack && (
<IconButton onClick={onBack} size="small" sx={{ display: { md: 'none' } }}>
<Iconify icon="eva:arrow-back-outline" />
</IconButton>
)}
<Avatar sx={{ width: 38, height: 38 }}>{getChatInitials(chat.participant_name)}</Avatar>
<Box>
<Typography variant="subtitle2">{chat.participant_name || 'Чат'}</Typography>
{chat.chat_type === 'group' ? (
<Typography variant="caption" color="text.secondary">
{chat.participants_count ? `${chat.participants_count} участника(ов)` : 'Группа'}
</Typography>
) : (
chat.other_is_online && (
<Typography variant="caption" color="success.main">
Онлайн
</Typography>
)
)}
</Box>
</Stack>
)}
{/* Messages */}
<Box
ref={listRef}
sx={{ flex: 1, overflowY: 'auto', px: 2, py: 1, display: 'flex', flexDirection: 'column', gap: 0.75 }}
onWheel={(e) => {
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 && (
<Typography variant="caption" color="text.secondary" textAlign="center">
Загрузка
</Typography>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : (
grouped.map((item) => {
if (item.type === 'day') {
return (
<Box key={item.key} sx={{ alignSelf: 'center', my: 0.5 }}>
<Typography
variant="caption"
sx={{ px: 1.5, py: 0.4, borderRadius: 999, bgcolor: 'background.neutral', color: 'text.secondary' }}
>
{item.label}
</Typography>
</Box>
);
}
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 (
<Box
key={item.key}
data-message-uuid={msgUuid || undefined}
data-is-mine={String(isMine)}
sx={{
alignSelf: isSystem ? 'center' : isMine ? 'flex-end' : 'flex-start',
maxWidth: isSystem ? '85%' : '75%',
px: 1.5,
py: 0.75,
borderRadius: 2,
bgcolor: isSystem ? 'action.hover' : isMine ? 'primary.main' : 'background.paper',
color: isSystem ? 'text.secondary' : isMine ? 'primary.contrastText' : 'text.primary',
border: '1px solid',
borderColor: isSystem ? 'divider' : isMine ? 'transparent' : 'divider',
boxShadow: isMine ? 0 : 1,
}}
>
{senderName && (
<Typography variant="caption" sx={{ display: 'block', fontWeight: 600, mb: 0.25, color: 'primary.main' }}>
{senderName}
</Typography>
)}
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{stripHtml(msg.content || '')}
</Typography>
<Typography variant="caption" sx={{ display: 'block', textAlign: 'right', opacity: 0.6, mt: 0.25 }}>
{formatChatTime(msg.created_at)}
</Typography>
</Box>
);
})
)}
</Box>
{/* Input */}
<Divider />
{sendError && (
<Alert severity="error" onClose={() => setSendError(null)} sx={{ mx: 1.5, mt: 1, borderRadius: 1 }}>
{sendError}
</Alert>
)}
<Stack direction="row" spacing={1} sx={{ p: 1.5, flexShrink: 0 }}>
<TextField
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Сообщение…"
fullWidth
multiline
minRows={1}
maxRows={4}
size="small"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
}}
/>
<IconButton onClick={handleSend} disabled={!text.trim() || sending} color="primary" sx={{ alignSelf: 'flex-end' }}>
{sending ? <CircularProgress size={20} /> : <Iconify icon="eva:paper-plane-outline" />}
</IconButton>
</Stack>
</Box>
);
}

View File

@ -10,10 +10,16 @@ import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Avatar from '@mui/material/Avatar'; import Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button'; 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 Typography from '@mui/material/Typography';
import CardContent from '@mui/material/CardContent'; 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 CircularProgress from '@mui/material/CircularProgress';
import InputAdornment from '@mui/material/InputAdornment';
import { paths } from 'src/routes/paths'; import { paths } from 'src/routes/paths';
@ -27,10 +33,95 @@ import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
async function getChildren() { async function getChildren() {
const res = await axios.get('/users/parents/children/'); const res = await axios.get('/parent/dashboard/');
const {data} = res; const raw = res.data?.children ?? [];
if (Array.isArray(data)) return data; return raw.map((item) => {
return data?.results ?? []; 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 (
<Dialog open={open} onClose={handleClose} maxWidth="xs" fullWidth>
<DialogTitle>Добавить ребёнка по коду</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Введите 8-значный код из профиля ребёнка. Код содержит буквы и цифры.
</Typography>
<TextField
autoFocus
fullWidth
label="Код ребёнка"
placeholder="Например: 8XW4EIVL"
value={code}
onChange={(e) => {
setCode(e.target.value.toUpperCase());
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
inputProps={{ maxLength: 8, style: { letterSpacing: 4, fontWeight: 600, fontSize: 18 } }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Iconify icon="solar:key-bold" width={20} />
</InputAdornment>
),
}}
error={!!error}
helperText={error || ' '}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} color="inherit" disabled={loading}>
Отмена
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={loading || code.trim().length !== 8}
startIcon={loading ? <CircularProgress size={16} /> : <Iconify icon="solar:user-plus-bold" width={18} />}
>
Добавить
</Button>
</DialogActions>
</Dialog>
);
} }
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
@ -40,10 +131,12 @@ export function ChildrenView() {
const [children, setChildren] = useState([]); const [children, setChildren] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [addOpen, setAddOpen] = useState(false);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
setError(null);
const list = await getChildren(); const list = await getChildren();
setChildren(list); setChildren(list);
} catch (e) { } catch (e) {
@ -53,15 +146,22 @@ export function ChildrenView() {
} }
}, []); }, []);
useEffect(() => { useEffect(() => { load(); }, [load]);
load();
}, [load]);
return ( return (
<DashboardContent> <DashboardContent>
<CustomBreadcrumbs <CustomBreadcrumbs
heading="Мои дети" heading="Мои дети"
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Мои дети' }]} links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Мои дети' }]}
action={
<Button
variant="contained"
startIcon={<Iconify icon="solar:user-plus-bold" width={18} />}
onClick={() => setAddOpen(true)}
>
Добавить ребёнка
</Button>
}
sx={{ mb: 3 }} sx={{ mb: 3 }}
/> />
@ -81,6 +181,14 @@ export function ChildrenView() {
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}> <Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
Нет привязанных детей Нет привязанных детей
</Typography> </Typography>
<Button
variant="outlined"
sx={{ mt: 2 }}
startIcon={<Iconify icon="solar:user-plus-bold" width={18} />}
onClick={() => setAddOpen(true)}
>
Добавить ребёнка по коду
</Button>
</Box> </Box>
) : ( ) : (
<Grid container spacing={2}> <Grid container spacing={2}>
@ -106,10 +214,14 @@ export function ChildrenView() {
<Button <Button
variant="contained" variant="contained"
fullWidth fullWidth
startIcon={<Iconify icon="eva:trending-up-outline" />} startIcon={<Iconify icon="solar:chart-square-bold" />}
onClick={() => router.push(`${paths.dashboard.childrenProgress}?child=${child.id}`)} onClick={() => {
localStorage.setItem('selected_child', JSON.stringify({ id: child.id, name }));
window.dispatchEvent(new Event('child-changed'));
router.push(paths.dashboard.root);
}}
> >
Прогресс Дашборд
</Button> </Button>
</CardActions> </CardActions>
</Card> </Card>
@ -118,6 +230,15 @@ export function ChildrenView() {
})} })}
</Grid> </Grid>
)} )}
<AddChildDialog
open={addOpen}
onClose={() => setAddOpen(false)}
onSuccess={() => {
load();
window.dispatchEvent(new Event('child-changed'));
}}
/>
</DashboardContent> </DashboardContent>
); );
} }

View File

@ -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 (
<Card sx={{ p: 2.5 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<Box
sx={{
width: 48, height: 48, borderRadius: 1.5,
bgcolor: `${color}.lighter`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}
>
<Iconify icon={icon} width={24} sx={{ color: `${color}.main` }} />
</Box>
<Box>
<Typography variant="h4">{value ?? '—'}</Typography>
<Typography variant="caption" color="text.secondary">{label}</Typography>
</Box>
</Stack>
</Card>
);
}
// ----------------------------------------------------------------------
// 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 (
<Stack spacing={2}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Typography variant="subtitle1">
Участники ({group.students?.length ?? 0})
</Typography>
{isMentor && (
<Button
size="small"
variant="contained"
startIcon={<Iconify icon="mingcute:add-line" />}
onClick={() => setAddOpen(true)}
>
Добавить
</Button>
)}
</Stack>
{group.students?.length === 0 && (
<Box sx={{ py: 6, textAlign: 'center' }}>
<Iconify icon="solar:users-group-rounded-linear" width={48} sx={{ color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">Нет участников</Typography>
</Box>
)}
<List disablePadding>
{(group.students || []).map((s) => (
<ListItem
key={s.id}
divider
secondaryAction={isMentor ? (
<IconButton size="small" color="error" disabled={loading} onClick={() => handleRemove(s.id)}>
<Iconify icon="solar:minus-circle-bold" width={20} />
</IconButton>
) : undefined}
>
<ListItemAvatar>
<Avatar src={studentAvatar(s)} sx={{ width: 36, height: 36, fontSize: 14 }}>
{studentInitials(s)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={studentName(s)}
secondary={s.user?.email || ''}
primaryTypographyProps={{ variant: 'body2' }}
secondaryTypographyProps={{ variant: 'caption' }}
/>
</ListItem>
))}
</List>
{/* Диалог добавления */}
<Dialog open={addOpen} onClose={() => setAddOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Добавить участника</DialogTitle>
<DialogContent sx={{ p: 0 }}>
<Box sx={{ px: 2, pt: 1.5, pb: 0.5 }}>
<TextField
size="small"
fullWidth
placeholder="Поиск ученика..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: <Iconify icon="eva:search-fill" width={18} sx={{ mr: 1, color: 'text.disabled' }} />,
}}
/>
</Box>
<List dense sx={{ maxHeight: 320, overflowY: 'auto' }}>
{notMembers.length === 0 && (
<ListItem><ListItemText primary="Все ученики уже в группе" /></ListItem>
)}
{notMembers.map((s) => (
<ListItem
key={s.id}
secondaryAction={
<IconButton size="small" color="primary" disabled={loading} onClick={() => handleAdd(s.id)}>
<Iconify icon="solar:add-circle-bold" width={20} />
</IconButton>
}
>
<ListItemAvatar>
<Avatar src={studentAvatar(s)} sx={{ width: 32, height: 32, fontSize: 13 }}>
{studentInitials(s)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={studentName(s)}
primaryTypographyProps={{ variant: 'body2' }}
/>
</ListItem>
))}
</List>
</DialogContent>
<DialogActions>
<Button variant="outlined" color="inherit" onClick={() => setAddOpen(false)}>Закрыть</Button>
</DialogActions>
</Dialog>
</Stack>
);
}
// ----------------------------------------------------------------------
// 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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>;
const filtered = statusFilter === 'all' ? lessons : lessons.filter((l) => l.status === statusFilter);
return (
<Stack spacing={2}>
<Stack direction="row" spacing={1}>
{['all', 'scheduled', 'completed', 'cancelled'].map((s) => (
<Chip
key={s}
label={s === 'all' ? `Все (${lessons.length})` : STATUS_MAP[s]?.label}
size="small"
color={statusFilter === s ? 'primary' : 'default'}
variant={statusFilter === s ? 'filled' : 'outlined'}
onClick={() => setStatusFilter(s)}
/>
))}
</Stack>
{filtered.length === 0 ? (
<Box sx={{ py: 6, textAlign: 'center' }}>
<Iconify icon="solar:calendar-linear" width={48} sx={{ color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">Нет занятий</Typography>
</Box>
) : (
<TableContainer component={Card} variant="outlined">
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Дата и время</TableCell>
<TableCell>Предмет</TableCell>
<TableCell>Статус</TableCell>
<TableCell align="right">Стоимость</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{filtered.map((lesson) => {
const cfg = STATUS_MAP[lesson.status] || { label: lesson.status, color: 'default' };
return (
<TableRow key={lesson.id} hover>
<TableCell>
<Typography variant="body2">{fDateTime(lesson.start_time)}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2">{lesson.subject_name || '—'}</Typography>
</TableCell>
<TableCell>
<Chip label={cfg.label} color={cfg.color} size="small" />
</TableCell>
<TableCell align="right">
<Typography variant="body2">{lesson.price ? `${lesson.price}` : '—'}</Typography>
</TableCell>
<TableCell align="right">
<IconButton
size="small"
onClick={() => router.push(paths.dashboard.lesson(lesson.id))}
>
<Iconify icon="solar:arrow-right-linear" width={16} />
</IconButton>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
)}
</Stack>
);
}
// ----------------------------------------------------------------------
// 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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>;
if (homeworks.length === 0) {
return (
<Box sx={{ py: 8, textAlign: 'center' }}>
<Iconify icon="solar:clipboard-linear" width={48} sx={{ color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">Нет домашних заданий</Typography>
</Box>
);
}
return (
<Stack spacing={1.5}>
{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 (
<Card
key={hw.id}
variant="outlined"
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
onClick={() => router.push(paths.dashboard.homeworkDetail(hw.id))}
>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
<Box flex={1}>
<Typography variant="subtitle2">{hw.title}</Typography>
{hw.deadline && (
<Typography variant="caption" color="text.secondary">
Дедлайн: {fDate(hw.deadline)}
</Typography>
)}
</Box>
<Stack alignItems="flex-end" spacing={0.5}>
<Chip
size="small"
label={hw.status === 'published' ? 'Опубликовано' : 'Черновик'}
color={hw.status === 'published' ? 'success' : 'default'}
/>
{assigned > 0 && (
<Typography variant="caption" color="text.secondary">
Решений: {submitted}/{assigned}
{checked > 0 && ` • Проверено: ${checked}`}
</Typography>
)}
</Stack>
</Stack>
</Card>
);
})}
</Stack>
);
}
// ----------------------------------------------------------------------
// 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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>;
if (materials.length === 0) {
return (
<Box sx={{ py: 8, textAlign: 'center' }}>
<Iconify icon="solar:folder-linear" width={48} sx={{ color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">У вас нет материалов</Typography>
</Box>
);
}
return (
<Stack spacing={1.5}>
<Alert severity="info" sx={{ mb: 0.5 }}>
Поделитесь материалами со всеми участниками группы ({group.students?.length ?? 0} чел.)
</Alert>
{materials.map((m) => {
const isShared = sharedIds.has(m.id);
return (
<Card key={m.id} variant="outlined" sx={{ p: 1.5 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box
sx={{
width: 40, height: 40, borderRadius: 1,
bgcolor: 'grey.100',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
>
<Iconify icon={getMaterialTypeIcon(m)} width={22} sx={{ color: 'text.secondary' }} />
</Box>
<Box flex={1} minWidth={0}>
<Typography variant="body2" fontWeight={500} noWrap>{m.title}</Typography>
{m.description && (
<Typography variant="caption" color="text.secondary" noWrap>{m.description}</Typography>
)}
</Box>
<Button
size="small"
variant={isShared ? 'soft' : 'outlined'}
color={isShared ? 'success' : 'primary'}
startIcon={
<Iconify
icon={isShared ? 'solar:check-circle-bold' : 'solar:share-bold'}
width={16}
/>
}
disabled={sharing === m.id || isShared}
onClick={() => handleShare(m.id)}
sx={{ flexShrink: 0 }}
>
{/* eslint-disable-next-line no-nested-ternary */}
{isShared ? 'Отправлено' : sharing === m.id ? '...' : 'Поделиться'}
</Button>
</Stack>
</Card>
);
})}
</Stack>
);
}
// ----------------------------------------------------------------------
// 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 (
<Box
sx={{
px: 1, py: 0.4, borderRadius: 0.75,
bgcolor: cfg.bg, color: cfg.color,
fontSize: 11, fontWeight: 600,
whiteSpace: 'nowrap', textAlign: 'center',
minWidth: 64,
}}
>
{status === 'graded' && score != null ? `${score}/${maxScore}` : cfg.label}
</Box>
);
}
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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>;
if (error) return <Alert severity="error">{error}</Alert>;
if (!matrix || matrix.homeworks.length === 0) {
return (
<Box sx={{ py: 8, textAlign: 'center' }}>
<Iconify icon="solar:chart-square-linear" width={48} sx={{ color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">Нет опубликованных домашних заданий</Typography>
</Box>
);
}
const { students, homeworks, cells } = matrix;
return (
<Box sx={{ overflowX: 'auto' }}>
{/* Легенда */}
<Stack direction="row" flexWrap="wrap" gap={1} mb={2}>
{Object.entries(SUBMISSION_STATUS).map(([key, cfg]) => (
<Box key={key} sx={{ px: 1.2, py: 0.3, borderRadius: 0.75, bgcolor: cfg.bg, color: cfg.color, fontSize: 11, fontWeight: 600 }}>
{cfg.label}
</Box>
))}
</Stack>
<TableContainer component={Card} variant="outlined">
<Table size="small" sx={{ minWidth: 500 }}>
<TableHead>
<TableRow sx={{ bgcolor: 'background.neutral' }}>
<TableCell sx={{ minWidth: 160, fontWeight: 600 }}>Ученик</TableCell>
{homeworks.map((hw) => (
<TableCell key={hw.id} align="center" sx={{ minWidth: 90 }}>
<Typography variant="caption" fontWeight={600} noWrap title={hw.title} sx={{ display: 'block', maxWidth: 88, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{hw.title}
</Typography>
{hw.deadline && (
<Typography variant="caption" color="text.disabled" display="block">
{fDate(hw.deadline)}
</Typography>
)}
</TableCell>
))}
<TableCell align="center" sx={{ minWidth: 80, fontWeight: 600 }}>Сдано</TableCell>
</TableRow>
</TableHead>
<TableBody>
{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 (
<TableRow key={s.id} hover>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<Avatar src={studentAvatar(s)} sx={{ width: 28, height: 28, fontSize: 12 }}>
{studentInitials(s)}
</Avatar>
<Typography variant="body2" noWrap sx={{ maxWidth: 120 }}>
{studentName(s)}
</Typography>
</Stack>
</TableCell>
{homeworks.map((hw) => {
const cell = studentCells[hw.id];
return (
<TableCell key={hw.id} align="center">
<SubmissionCell
status={cell?.status ?? 'pending'}
score={cell?.score}
maxScore={cell?.maxScore}
/>
</TableCell>
);
})}
<TableCell align="center">
<Typography
variant="caption"
fontWeight={700}
color={submittedCount === homeworks.length ? 'success.main' : 'text.secondary'}
>
{submittedCount}/{homeworks.length}
</Typography>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}
// ----------------------------------------------------------------------
// 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 (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Редактировать группу</DialogTitle>
<DialogContent>
<Stack spacing={2} pt={1}>
{error && <Alert severity="error">{error}</Alert>}
<TextField label="Название *" value={name} onChange={(e) => setName(e.target.value)} fullWidth autoFocus />
<TextField label="Описание" value={description} onChange={(e) => setDescription(e.target.value)} fullWidth multiline rows={2} />
</Stack>
</DialogContent>
<DialogActions>
<Button variant="outlined" color="inherit" onClick={onClose}>Отмена</Button>
<Button variant="contained" onClick={handleSave} disabled={loading}>
{loading ? 'Сохранение...' : 'Сохранить'}
</Button>
</DialogActions>
</Dialog>
);
}
// ----------------------------------------------------------------------
// 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 (
<DashboardContent>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>
</DashboardContent>
);
}
if (!group) {
return (
<DashboardContent>
<Alert severity="error">Группа не найдена</Alert>
</DashboardContent>
);
}
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 (
<DashboardContent>
<CustomBreadcrumbs
heading={group.name}
links={[
{ name: 'Главная', href: paths.dashboard.root },
{ name: 'Группы', href: paths.dashboard.groups },
{ name: group.name },
]}
sx={{ mb: 3 }}
/>
{/* Шапка */}
<Card sx={{ p: 3, mb: 3 }}>
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2.5} alignItems={{ sm: 'center' }}>
<Box
sx={{
width: 72, height: 72, borderRadius: 2,
bgcolor: 'primary.lighter',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}
>
<Iconify icon="solar:users-group-rounded-bold" width={36} sx={{ color: 'primary.main' }} />
</Box>
<Box flex={1}>
<Typography variant="h5">{group.name}</Typography>
{group.description && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 0.5 }}>
{group.description}
</Typography>
)}
<Typography variant="caption" color="text.disabled" sx={{ display: 'block', mt: 0.5 }}>
Создана {fDate(group.created_at)}
</Typography>
</Box>
<Stack direction="row" spacing={1} flexShrink={0}>
{isMentor && (
<Button
variant="outlined"
startIcon={<Iconify icon="solar:pen-bold" width={16} />}
onClick={() => setEditOpen(true)}
>
Редактировать
</Button>
)}
<Button
variant="contained"
color="info"
startIcon={<Iconify icon="solar:pen-new-square-bold" width={16} />}
onClick={handleOpenBoard}
disabled={boardLoading}
>
{boardLoading ? 'Загрузка...' : 'Открыть доску'}
</Button>
</Stack>
</Stack>
</Card>
{/* Статистика */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={6} sm={3}>
<StatCard icon="solar:users-group-rounded-bold" label="Участников" value={studentCount} color="primary" />
</Grid>
<Grid item xs={6} sm={3}>
<StatCard icon="solar:calendar-bold" label="Всего занятий" value={totalLessons} color="info" />
</Grid>
<Grid item xs={6} sm={3}>
<StatCard icon="solar:check-circle-bold" label="Завершено" value={completedLessons} color="success" />
</Grid>
<Grid item xs={6} sm={3}>
<StatCard icon="solar:clock-circle-bold" label="Запланировано" value={scheduledLessons} color="warning" />
</Grid>
</Grid>
{/* Аватары участников (превью) */}
{group.students && group.students.length > 0 && (
<Card sx={{ p: 2.5, mb: 3 }}>
<Typography variant="subtitle2" sx={{ mb: 1.5 }}>Участники группы</Typography>
<Stack direction="row" flexWrap="wrap" gap={1.5}>
{group.students.map((s) => (
<Stack key={s.id} alignItems="center" spacing={0.5} sx={{ minWidth: 60 }}>
<Avatar src={studentAvatar(s)} sx={{ width: 44, height: 44, fontSize: 16 }}>
{studentInitials(s)}
</Avatar>
<Typography variant="caption" align="center" noWrap sx={{ maxWidth: 70, display: 'block' }}>
{(s.user?.first_name || studentName(s)).split(' ')[0]}
</Typography>
</Stack>
))}
</Stack>
</Card>
)}
{/* Табы */}
<Card>
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
sx={{ px: 2, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Tab
value="students"
label="Участники"
icon={<Iconify icon="solar:users-group-rounded-bold" width={18} />}
iconPosition="start"
/>
<Tab
value="lessons"
label="Занятия"
icon={<Iconify icon="solar:calendar-bold" width={18} />}
iconPosition="start"
/>
<Tab
value="homework"
label="Домашние задания"
icon={<Iconify icon="solar:clipboard-bold" width={18} />}
iconPosition="start"
/>
{isMentor && (
<Tab
value="materials"
label="Материалы"
icon={<Iconify icon="solar:folder-bold" width={18} />}
iconPosition="start"
/>
)}
<Tab
value="progress"
label="Прогресс"
icon={<Iconify icon="solar:chart-square-bold" width={18} />}
iconPosition="start"
/>
</Tabs>
<Box sx={{ p: 3 }}>
{tab === 'students' && (
<StudentsTab group={group} onRefresh={loadGroup} allStudents={allStudents} isMentor={isMentor} />
)}
{tab === 'lessons' && (
<LessonsTab groupId={id} />
)}
{tab === 'homework' && (
<HomeworkTab groupId={id} />
)}
{tab === 'materials' && isMentor && (
<MaterialsTab group={group} />
)}
{tab === 'progress' && (
<ProgressTab groupId={id} group={group} />
)}
</Box>
</Card>
<EditGroupDialog
open={editOpen}
onClose={() => setEditOpen(false)}
group={group}
onSaved={loadGroup}
/>
</DashboardContent>
);
}

View File

@ -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 (
<Avatar src={avatarUrl} sx={{ width: size, height: size, fontSize: size * 0.4 }}>
{name.charAt(0).toUpperCase()}
</Avatar>
);
}
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 (
<Card>
<CardHeader
title={
<Typography
variant="subtitle1"
sx={{ cursor: 'pointer', '&:hover': { color: 'primary.main' } }}
onClick={() => router.push(paths.dashboard.groupDetail(group.id))}
>
{group.name}
</Typography>
}
subheader={group.description || 'Без описания'}
action={
<Stack direction="row" spacing={0.5}>
<IconButton size="small" onClick={() => router.push(paths.dashboard.groupDetail(group.id))} title="Открыть">
<Iconify icon="solar:arrow-right-up-linear" width={18} />
</IconButton>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onManageStudents(group); }}>
<Iconify icon="solar:users-group-rounded-bold" width={18} />
</IconButton>
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(group); }}>
<Iconify icon="solar:pen-bold" width={18} />
</IconButton>
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); onDelete(group); }}>
<Iconify icon="solar:trash-bin-trash-bold" width={18} />
</IconButton>
</Stack>
}
/>
<CardContent sx={{ pt: 0 }}>
<Stack direction="row" spacing={2} mb={2} flexWrap="wrap" gap={1}>
<Chip
size="small"
icon={<Iconify icon="solar:user-bold" width={14} />}
label={`${studentCount} ${studentCount === 1 ? 'ученик' : studentCount < 5 ? 'ученика' : 'учеников'}`}
/>
{scheduledLessons > 0 && (
<Chip size="small" color="primary" variant="outlined"
label={`${scheduledLessons} запланировано`} />
)}
{completedLessons > 0 && (
<Chip size="small" color="success" variant="outlined"
label={`${completedLessons} завершено`} />
)}
</Stack>
{group.students && group.students.length > 0 && (
<Stack direction="row" spacing={-0.5}>
{group.students.slice(0, 6).map((s) => (
<StudentAvatar key={s.id} student={s} size={32} />
))}
{group.students.length > 6 && (
<Avatar sx={{ width: 32, height: 32, fontSize: 12, bgcolor: 'grey.300', color: 'text.secondary' }}>
+{group.students.length - 6}
</Avatar>
)}
</Stack>
)}
</CardContent>
</Card>
);
}
// ----------------------------------------------------------------------
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 (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>{initial?.id ? 'Редактировать группу' : 'Создать группу'}</DialogTitle>
<DialogContent>
<Stack spacing={2} pt={1}>
{error && <Alert severity="error">{error}</Alert>}
<TextField
label="Название *"
value={name}
onChange={(e) => setName(e.target.value)}
fullWidth
autoFocus
/>
<TextField
label="Описание"
value={description}
onChange={(e) => setDescription(e.target.value)}
fullWidth
multiline
rows={2}
/>
</Stack>
</DialogContent>
<DialogActions>
<Button variant="outlined" color="inherit" onClick={onClose}>Отмена</Button>
<Button variant="contained" onClick={handleSave} disabled={loading}>
{loading ? 'Сохранение...' : (initial?.id ? 'Сохранить' : 'Создать')}
</Button>
</DialogActions>
</Dialog>
);
}
// ----------------------------------------------------------------------
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 (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle>Ученики {group.name}</DialogTitle>
<DialogContent sx={{ p: 0 }}>
<Box sx={{ px: 2, pt: 1, pb: 0.5 }}>
<TextField
size="small"
fullWidth
placeholder="Поиск ученика..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
InputProps={{
startAdornment: <Iconify icon="eva:search-fill" width={18} sx={{ mr: 1, color: 'text.disabled' }} />,
}}
/>
</Box>
<List dense sx={{ maxHeight: 360, overflowY: 'auto' }}>
{filtered.length === 0 && (
<ListItem>
<ListItemText primary="Нет учеников" secondary="Добавьте учеников через раздел Ученики" />
</ListItem>
)}
{filtered.map((student) => {
const isMember = memberIds.has(student.id);
const avatarUrl = student.user?.avatar ? resolveMediaUrl(student.user.avatar) : null;
const name = student.user?.full_name ||
`${student.user?.first_name || ''} ${student.user?.last_name || ''}`.trim() ||
student.user?.email || `ID: ${student.id}`;
return (
<ListItem
key={student.id}
secondaryAction={
<IconButton
edge="end"
size="small"
color={isMember ? 'error' : 'primary'}
disabled={loading}
onClick={() => handleToggle(student.id)}
>
<Iconify icon={isMember ? 'solar:minus-circle-bold' : 'solar:add-circle-bold'} width={20} />
</IconButton>
}
>
<ListItemAvatar>
<Avatar src={avatarUrl} sx={{ width: 32, height: 32, fontSize: 13 }}>
{name.charAt(0).toUpperCase()}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={name}
secondary={isMember ? 'В группе' : ''}
primaryTypographyProps={{ variant: 'body2' }}
secondaryTypographyProps={{ variant: 'caption', color: 'success.main' }}
/>
</ListItem>
);
})}
</List>
</DialogContent>
<DialogActions>
<Button variant="outlined" color="inherit" onClick={onClose}>Закрыть</Button>
</DialogActions>
</Dialog>
);
}
// ----------------------------------------------------------------------
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 (
<DashboardContent>
<Stack direction="row" alignItems="center" justifyContent="space-between" mb={3}>
<Typography variant="h4">Группы</Typography>
<Button
variant="contained"
startIcon={<Iconify icon="mingcute:add-line" />}
onClick={handleOpenCreate}
>
Создать группу
</Button>
</Stack>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
{loading ? (
<Stack alignItems="center" py={8}>
<CircularProgress />
</Stack>
) : groups.length === 0 ? (
<Card>
<CardContent>
<Stack alignItems="center" py={6} spacing={2}>
<Iconify icon="solar:users-group-rounded-bold" width={48} sx={{ color: 'text.disabled' }} />
<Typography variant="h6" color="text.secondary">Нет групп</Typography>
<Typography variant="body2" color="text.disabled">
Создайте первую учебную группу для организации групповых занятий
</Typography>
<Button variant="contained" onClick={handleOpenCreate}
startIcon={<Iconify icon="mingcute:add-line" />}>
Создать группу
</Button>
</Stack>
</CardContent>
</Card>
) : (
<Grid container spacing={3}>
{groups.map((group) => (
<Grid item xs={12} sm={6} md={4} key={group.id}>
<GroupCard
group={group}
onEdit={handleOpenEdit}
onDelete={setDeleteDialogGroup}
onManageStudents={handleManageStudents}
/>
</Grid>
))}
</Grid>
)}
<GroupFormDialog
open={formOpen}
onClose={() => setFormOpen(false)}
onSave={handleSaveGroup}
initial={editingGroup}
/>
<ManageStudentsDialog
open={manageOpen}
onClose={() => { setManageOpen(false); setManagingGroup(null); }}
group={managingGroup}
allStudents={allStudents}
onRefresh={handleRefreshManaging}
/>
{/* Delete confirm */}
<Dialog open={!!deleteDialogGroup} onClose={() => setDeleteDialogGroup(null)} maxWidth="xs" fullWidth>
<DialogTitle>Удалить группу?</DialogTitle>
<DialogContent>
<Typography>
Группа <strong>{deleteDialogGroup?.name}</strong> будет удалена. Это действие нельзя отменить.
</Typography>
</DialogContent>
<DialogActions>
<Button variant="outlined" color="inherit" onClick={() => setDeleteDialogGroup(null)}>Отмена</Button>
<Button variant="contained" color="error" onClick={handleDelete} disabled={deleteLoading}>
{deleteLoading ? 'Удаление...' : 'Удалить'}
</Button>
</DialogActions>
</Dialog>
</DashboardContent>
);
}

View File

@ -0,0 +1,2 @@
export { GroupsView } from './groups-view';
export { GroupDetailView } from './group-detail-view';

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ import CardActionArea from '@mui/material/CardActionArea';
import CircularProgress from '@mui/material/CircularProgress'; 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';
@ -85,12 +86,30 @@ function HomeworkCard({ hw, userRole, onView, onSubmit, onEdit }) {
</Stack> </Stack>
)} )}
{userRole === 'mentor' && hw.total_submissions > 0 && ( {userRole === 'mentor' && (() => {
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}> const assignedCount = Array.isArray(hw.assigned_to) ? hw.assigned_to.length : 0;
const isGroup = assignedCount > 1;
return (
<Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 0.5, flexWrap: 'wrap', gap: 0.5 }}>
{isGroup && (
<Chip
size="small"
icon={<Iconify icon="solar:users-group-rounded-bold" width={13} />}
label={`Группа • ${assignedCount} уч.`}
variant="outlined"
color="primary"
sx={{ height: 20, fontSize: 11 }}
/>
)}
{hw.total_submissions > 0 && (
<Typography variant="caption" color="text.secondary">
Решений: {hw.total_submissions} Решений: {hw.total_submissions}
{hw.checked_submissions > 0 && ` • Проверено: ${hw.checked_submissions}`} {hw.checked_submissions > 0 && ` • Проверено: ${hw.checked_submissions}`}
</Typography> </Typography>
)} )}
</Stack>
);
})()}
{hw.deadline && ( {hw.deadline && (
<Stack direction="row" alignItems="center" spacing={0.5}> <Stack direction="row" alignItems="center" spacing={0.5}>
@ -196,8 +215,23 @@ function HomeworkColumn({ title, items, userRole, onView, onSubmit, onEdit, empt
export function HomeworkView() { export function HomeworkView() {
const { user } = useAuthContext(); const { user } = useAuthContext();
const router = useRouter();
const userRole = user?.role ?? ''; 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 [homework, setHomework] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
@ -213,28 +247,24 @@ export function HomeworkView() {
const loadHomework = useCallback(async () => { const loadHomework = useCallback(async () => {
try { try {
setLoading(true); 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); setHomework(res.results);
} catch (e) { } catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки'); setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [childId]);
useEffect(() => { useEffect(() => {
loadHomework(); loadHomework();
}, [loadHomework]); }, [loadHomework]);
const handleViewDetails = useCallback(async (hw) => { const handleViewDetails = useCallback((hw) => {
try { router.push(paths.dashboard.homeworkDetail(hw.id));
const full = await getHomeworkById(hw.id); }, [router]);
setSelectedHw(full);
} catch {
setSelectedHw(hw);
}
setDetailsOpen(true);
}, []);
const handleOpenSubmit = useCallback((hw) => { const handleOpenSubmit = useCallback((hw) => {
setSubmitHwId(hw.id); setSubmitHwId(hw.id);

View File

@ -0,0 +1 @@
export { LessonDetailView } from './lesson-detail-view';

File diff suppressed because it is too large Load Diff

View File

@ -56,7 +56,7 @@ async function getLessons(params) {
if (params?.start_date) q.append('start_date', params.start_date); if (params?.start_date) q.append('start_date', params.start_date);
if (params?.end_date) q.append('end_date', params.end_date); if (params?.end_date) q.append('end_date', params.end_date);
if (params?.child_id) q.append('child_id', params.child_id); 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; const {data} = res;
if (Array.isArray(data)) return { results: data }; if (Array.isArray(data)) return { results: data };
return data; return data;
@ -116,11 +116,19 @@ export function MyProgressView() {
const [subjects, setSubjects] = useState([]); const [subjects, setSubjects] = useState([]);
const [selectedSubject, setSelectedSubject] = useState(''); const [selectedSubject, setSelectedSubject] = useState('');
const childId = isParent const getChildId = () => {
? typeof window !== 'undefined' if (!isParent) return '';
? localStorage.getItem('selected_child_id') || '' 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 loadSubjects = useCallback(async () => {
const sixMonthsAgo = new Date(Date.now() - 180 * 86400000).toISOString().slice(0, 10); const sixMonthsAgo = new Date(Date.now() - 180 * 86400000).toISOString().slice(0, 10);

View File

@ -13,6 +13,7 @@ import CircularProgress from '@mui/material/CircularProgress';
import LinearProgress from '@mui/material/LinearProgress'; import LinearProgress from '@mui/material/LinearProgress';
import { useTheme } from '@mui/material/styles'; import { useTheme } from '@mui/material/styles';
import { paths } from 'src/routes/paths';
import { useRouter } from 'src/routes/hooks'; import { useRouter } from 'src/routes/hooks';
import { fDateTime } from 'src/utils/format-time'; import { fDateTime } from 'src/utils/format-time';
import { getClientDashboard, getChildDashboard } from 'src/utils/dashboard-api'; import { getClientDashboard, getChildDashboard } from 'src/utils/dashboard-api';
@ -66,7 +67,7 @@ function LessonItem({ lesson }) {
setJoining(true); setJoining(true);
const res = await createLiveKitRoom(lesson.id); const res = await createLiveKitRoom(lesson.id);
const token = res?.access_token || res?.token; 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) { } catch (err) {
console.error('Join error:', err); console.error('Join error:', err);
setJoining(false); setJoining(false);
@ -203,6 +204,16 @@ export function OverviewClientView({ childId, childName }) {
return () => controller.abort(); return () => controller.abort();
}, [fetchData]); }, [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 displayName = childName || user?.first_name || 'Студент';
const completionPct = const completionPct =

View File

@ -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 (
<Container maxWidth={settings.themeStretch ? false : 'xl'}>
<Stack spacing={3}>
<Stack spacing={1}>
<Typography variant="h4">Подключение к занятию</Typography>
<CustomBreadcrumbs
links={[
{ name: 'Дашборд', href: paths.dashboard.root },
{ name: 'Расписание', href: paths.dashboard.calendar },
{ name: 'Подключение' },
]}
/>
</Stack>
<Card>
<Box
sx={{
display: 'grid',
gridTemplateColumns: { xs: '1fr', md: '1fr 420px' },
}}
>
{/* Left — camera preview */}
<Box
sx={{
p: 3,
display: 'flex',
flexDirection: 'column',
gap: 2,
bgcolor: (t) => varAlpha(t.vars.palette.grey['500Channel'], 0.04),
borderRight: { md: '1px solid' },
borderColor: { md: 'divider' },
}}
>
{/* Video preview */}
<Box
sx={{
position: 'relative',
width: 1,
aspectRatio: '16/9',
borderRadius: 2,
overflow: 'hidden',
bgcolor: 'grey.900',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<video
ref={videoRef}
autoPlay
playsInline
muted
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
display: camEnabled ? 'block' : 'none',
transform: 'scaleX(-1)',
}}
/>
{!camEnabled && (
<Stack alignItems="center" spacing={1.5}>
<Box
sx={{
width: 72,
height: 72,
borderRadius: '50%',
bgcolor: (t) => varAlpha(t.vars.palette.grey['500Channel'], 0.16),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Iconify icon="solar:camera-slash-bold" width={32} sx={{ color: 'grey.500' }} />
</Box>
<Typography variant="caption" color="grey.500">
Камера отключена
</Typography>
</Stack>
)}
{camEnabled && (
<Chip
label="Превью"
size="small"
color="success"
variant="filled"
sx={{ position: 'absolute', top: 12, left: 12, fontSize: 11, height: 22 }}
/>
)}
</Box>
{/* Toggle buttons */}
<Stack direction="row" justifyContent="center" spacing={3}>
<Stack alignItems="center" spacing={0.5}>
<IconButton
onClick={() => setMicEnabled((v) => !v)}
sx={{
width: 52,
height: 52,
bgcolor: micEnabled ? 'primary.main' : 'error.main',
color: 'common.white',
'&:hover': { bgcolor: micEnabled ? 'primary.dark' : 'error.dark' },
}}
>
<Iconify
icon={micEnabled ? 'solar:microphone-bold' : 'solar:microphone-slash-bold'}
width={24}
/>
</IconButton>
<Typography variant="caption" color="text.secondary">
{micEnabled ? 'Микрофон вкл' : 'Микрофон выкл'}
</Typography>
</Stack>
<Stack alignItems="center" spacing={0.5}>
<IconButton
onClick={() => setCamEnabled((v) => !v)}
sx={{
width: 52,
height: 52,
bgcolor: camEnabled ? 'primary.main' : 'error.main',
color: 'common.white',
'&:hover': { bgcolor: camEnabled ? 'primary.dark' : 'error.dark' },
}}
>
<Iconify
icon={camEnabled ? 'solar:camera-bold' : 'solar:camera-slash-bold'}
width={24}
/>
</IconButton>
<Typography variant="caption" color="text.secondary">
{camEnabled ? 'Камера вкл' : 'Камера выкл'}
</Typography>
</Stack>
</Stack>
</Box>
{/* Right — settings + join */}
<Stack sx={{ p: 3 }} spacing={3} justifyContent="space-between">
<Stack spacing={3}>
<Box>
<Typography variant="h6" gutterBottom>
Настройте оборудование
</Typography>
<Typography variant="body2" color="text.secondary">
Проверьте камеру и микрофон перед входом в занятие.
</Typography>
</Box>
<Divider />
{/* Mic */}
<Stack spacing={1.5}>
<Stack direction="row" alignItems="center" spacing={1}>
<Iconify icon="solar:microphone-bold" width={18} sx={{ color: 'primary.main' }} />
<Typography variant="subtitle2">Микрофон</Typography>
<Box sx={{ flexGrow: 1 }} />
<Chip
size="small"
label={micEnabled ? 'Вкл' : 'Выкл'}
color={micEnabled ? 'success' : 'default'}
variant="soft"
sx={{ fontSize: 11, height: 20 }}
/>
</Stack>
{micDevices.length > 1 && (
<FormControl size="small" fullWidth disabled={!micEnabled}>
<Select
value={micDeviceId}
onChange={(e) => setMicDeviceId(e.target.value)}
sx={{ fontSize: 13 }}
>
{micDevices.map((d) => (
<MenuItem key={d.deviceId} value={d.deviceId} sx={{ fontSize: 13 }}>
{d.label || `Микрофон ${d.deviceId.slice(0, 8)}`}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Stack>
{/* Camera */}
<Stack spacing={1.5}>
<Stack direction="row" alignItems="center" spacing={1}>
<Iconify icon="solar:camera-bold" width={18} sx={{ color: 'primary.main' }} />
<Typography variant="subtitle2">Камера</Typography>
<Box sx={{ flexGrow: 1 }} />
<Chip
size="small"
label={camEnabled ? 'Вкл' : 'Выкл'}
color={camEnabled ? 'success' : 'default'}
variant="soft"
sx={{ fontSize: 11, height: 20 }}
/>
</Stack>
{camDevices.length > 1 && (
<FormControl size="small" fullWidth disabled={!camEnabled}>
<Select
value={camDeviceId}
onChange={(e) => setCamDeviceId(e.target.value)}
sx={{ fontSize: 13 }}
>
{camDevices.map((d) => (
<MenuItem key={d.deviceId} value={d.deviceId} sx={{ fontSize: 13 }}>
{d.label || `Камера ${d.deviceId.slice(0, 8)}`}
</MenuItem>
))}
</Select>
</FormControl>
)}
</Stack>
<Divider />
<Box
sx={{
p: 2,
borderRadius: 1.5,
bgcolor: (t) => varAlpha(t.vars.palette.info.mainChannel, 0.08),
border: '1px solid',
borderColor: (t) => varAlpha(t.vars.palette.info.mainChannel, 0.2),
}}
>
<Typography variant="caption" color="info.main">
Вы можете изменить настройки камеры и микрофона в любой момент во время занятия.
</Typography>
</Box>
</Stack>
<LoadingButton
fullWidth
size="large"
variant="contained"
color="primary"
loading={joining}
onClick={handleJoin}
startIcon={<Iconify icon="solar:videocamera-record-bold" />}
sx={{ mt: 1 }}
>
Подключиться
</LoadingButton>
</Stack>
</Box>
</Card>
</Stack>
</Container>
);
}

View File

@ -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 (
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
<DialogTitle>Обратная связь по уроку</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Typography variant="subtitle2" color="text.secondary">
{fDateTime(lesson.start_time)} · {lesson.title || 'Занятие'}
</Typography>
{error && <Alert severity="error">{error}</Alert>}
<TextField
label="Заметки и комментарий"
value={notes}
onChange={(e) => setNotes(e.target.value)}
multiline
minRows={3}
fullWidth
/>
<TextField
label="Оценка (0100)"
value={mentorGrade}
onChange={(e) => setMentorGrade(e.target.value)}
type="number"
inputProps={{ min: 0, max: 100 }}
fullWidth
/>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>Отмена</Button>
<Button onClick={handleSave} variant="contained" disabled={saving}>
{saving ? <CircularProgress size={18} /> : 'Сохранить'}
</Button>
</DialogActions>
</Dialog>
);
}
// ----------------------------------------------------------------------
// 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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>;
if (error) return <Alert severity="error">{error}</Alert>;
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 (
<Stack spacing={3}>
{/* Фильтры */}
<Card variant="outlined" sx={{ p: 2 }}>
<Stack spacing={2}>
{/* Быстрые пресеты */}
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{presets.map((p) => (
<Chip
key={p.days}
label={p.label}
size="small"
onClick={() => applyPreset(p.days)}
variant={currentDays === p.days ? 'filled' : 'outlined'}
color={currentDays === p.days ? 'primary' : 'default'}
sx={{ cursor: 'pointer' }}
/>
))}
</Stack>
{/* Произвольный диапазон */}
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} alignItems="flex-start">
<TextField
size="small"
label="С"
type="date"
value={range.start}
onChange={(e) => handleDateChange('start', e.target.value)}
inputProps={{ max: range.end || toISO(new Date()) }}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 160 }}
/>
<TextField
size="small"
label="По"
type="date"
value={range.end}
onChange={(e) => handleDateChange('end', e.target.value)}
inputProps={{ min: range.start, max: toISO(new Date()) }}
InputLabelProps={{ shrink: true }}
sx={{ minWidth: 160 }}
/>
<TextField
size="small"
label="Предмет"
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder="Математика..."
sx={{ minWidth: 180 }}
/>
<Button variant="contained" size="small" onClick={load} disabled={!!rangeError}>
Применить
</Button>
</Stack>
{rangeError && <Alert severity="error" sx={{ py: 0.5 }}>{rangeError}</Alert>}
{currentDays != null && !rangeError && (
<Typography variant="caption" color="text.secondary">
Выбрано: {currentDays} {currentDays === 1 ? 'день' : currentDays < 5 ? 'дня' : 'дней'} (макс. {MAX_DAYS})
</Typography>
)}
</Stack>
</Card>
{/* Общая статистика */}
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: 'repeat(2,1fr)', md: 'repeat(4,1fr)' }, gap: 2 }}>
{[
{ 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) => (
<Card key={s.label} sx={{ p: 2.5 }}>
<Stack direction="row" alignItems="center" spacing={1.5}>
<Box sx={{ width: 40, height: 40, borderRadius: 1.5, bgcolor: `${s.color}22`, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Iconify icon={s.icon} width={22} sx={{ color: s.color }} />
</Box>
<Box>
<Typography variant="h5" sx={{ lineHeight: 1.2 }}>{s.value}</Typography>
<Typography variant="caption" color="text.secondary">{s.label}</Typography>
</Box>
</Stack>
</Card>
))}
</Box>
{/* По предметам */}
{subjects.length > 0 && (
<Card>
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle1">По предметам</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Предмет</TableCell>
<TableCell align="center">Занятий</TableCell>
<TableCell align="center">Завершено</TableCell>
<TableCell align="center">Ср. оценка</TableCell>
<TableCell align="center">ДЗ всего</TableCell>
<TableCell align="center">ДЗ сдано</TableCell>
<TableCell>Прогресс</TableCell>
</TableRow>
</TableHead>
<TableBody>
{subjects.map((sub) => {
const pct = sub.total_lessons > 0 ? Math.round((sub.completed_lessons / sub.total_lessons) * 100) : 0;
return (
<TableRow key={sub.subject || sub.title}>
<TableCell><Typography variant="body2" fontWeight={500}>{sub.subject || sub.title || '—'}</Typography></TableCell>
<TableCell align="center">{sub.total_lessons ?? 0}</TableCell>
<TableCell align="center">{sub.completed_lessons ?? 0}</TableCell>
<TableCell align="center">
{sub.average_grade != null
? <Chip label={Number(sub.average_grade).toFixed(1)} size="small" color={gradeColor(sub.average_grade)} />
: '—'}
</TableCell>
<TableCell align="center">{sub.total_homeworks ?? 0}</TableCell>
<TableCell align="center">{sub.submitted_homeworks ?? 0}</TableCell>
<TableCell sx={{ minWidth: 120 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<LinearProgress variant="determinate" value={pct} sx={{ flex: 1, height: 6, borderRadius: 3 }} />
<Typography variant="caption" color="text.secondary">{pct}%</Typography>
</Stack>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Card>
)}
{/* Последние оценки */}
{(progress.progress_timeline || []).length > 0 && (
<Card>
<Box sx={{ px: 3, py: 2, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle1">Последние оценки</Typography>
</Box>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Дата</TableCell>
<TableCell>Тема</TableCell>
<TableCell align="center">Оценка</TableCell>
<TableCell>Комментарий</TableCell>
</TableRow>
</TableHead>
<TableBody>
{progress.progress_timeline.slice(0, 10).map((item, i) => (
<TableRow key={i}>
<TableCell><Typography variant="caption">{fDate(item.date || item.created_at)}</Typography></TableCell>
<TableCell>{item.lesson_title || item.title || '—'}</TableCell>
<TableCell align="center">
<Chip label={item.grade ?? item.mentor_grade ?? '—'} size="small" color={gradeColor(item.grade ?? item.mentor_grade)} />
</TableCell>
<TableCell>
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: 200, display: 'block' }}>
{item.notes || item.mentor_notes || '—'}
</Typography>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Card>
)}
</Stack>
);
}
// ----------------------------------------------------------------------
// 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 (
<Stack spacing={2}>
{/* Фильтр статуса */}
<Stack direction="row" spacing={1} flexWrap="wrap">
{[['', 'Все'], ['scheduled', 'Предстоящие'], ['completed', 'Завершённые'], ['cancelled', 'Отменённые']].map(([val, label]) => (
<Chip
key={val}
label={label}
onClick={() => setStatus(val)}
variant={status === val ? 'filled' : 'outlined'}
color={status === val ? 'primary' : 'default'}
sx={{ cursor: 'pointer' }}
/>
))}
</Stack>
{error && <Alert severity="error">{error}</Alert>}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
) : lessons.length === 0 ? (
<Box sx={{ py: 6, textAlign: 'center' }}>
<Iconify icon="solar:calendar-bold" width={40} sx={{ color: 'text.disabled', mb: 1 }} />
<Typography color="text.secondary">Занятий не найдено</Typography>
</Box>
) : (
<Card>
<TableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell>Дата</TableCell>
<TableCell>Тема</TableCell>
<TableCell align="center">Статус</TableCell>
<TableCell align="center">Оценка</TableCell>
<TableCell>Комментарий</TableCell>
<TableCell align="right">Действия</TableCell>
</TableRow>
</TableHead>
<TableBody>
{lessons.map((lesson) => {
const st = statusLabel(lesson.status);
const needsFeedback = lesson.status === 'completed' && !lesson.mentor_notes && lesson.mentor_grade == null;
return (
<TableRow key={lesson.id} hover>
<TableCell>
<Typography variant="body2">{fDateTime(lesson.start_time)}</Typography>
</TableCell>
<TableCell>
<Typography variant="body2" fontWeight={500}>{lesson.title || 'Занятие'}</Typography>
{lesson.description && (
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: 180, display: 'block' }}>
{lesson.description}
</Typography>
)}
</TableCell>
<TableCell align="center">
<Chip label={st.label} color={st.color} size="small" />
</TableCell>
<TableCell align="center">
{lesson.mentor_grade != null
? <Chip label={lesson.mentor_grade} size="small" color={gradeColor(lesson.mentor_grade)} />
: '—'}
</TableCell>
<TableCell>
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: 200, display: 'block' }}>
{lesson.mentor_notes || '—'}
</Typography>
</TableCell>
<TableCell align="right">
{lesson.status === 'completed' && (
<Button
size="small"
variant={needsFeedback ? 'contained' : 'outlined'}
color={needsFeedback ? 'warning' : 'inherit'}
startIcon={<Iconify icon="solar:pen-bold" width={16} />}
onClick={() => setFeedbackLesson(lesson)}
>
{needsFeedback ? 'Оставить отзыв' : 'Редактировать'}
</Button>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Card>
)}
<FeedbackDialog
lesson={feedbackLesson}
open={!!feedbackLesson}
onClose={() => setFeedbackLesson(null)}
onSaved={load}
/>
</Stack>
);
}
// ----------------------------------------------------------------------
// 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 <Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>;
if (lessons.length === 0) {
return (
<Box sx={{ py: 8, textAlign: 'center' }}>
<Iconify icon="solar:check-circle-bold" width={48} sx={{ color: 'success.main', mb: 2 }} />
<Typography variant="subtitle1">Всё проверено!</Typography>
<Typography variant="body2" color="text.secondary">Нет занятий, требующих обратной связи</Typography>
</Box>
);
}
return (
<Stack spacing={2}>
<Alert severity="warning" icon={<Iconify icon="solar:bell-bold" />}>
{lessons.length} {lessons.length === 1 ? 'занятие требует' : 'занятий требуют'} обратной связи
</Alert>
{lessons.map((lesson) => (
<Card key={lesson.id} sx={{ p: 2.5, border: '1px solid', borderColor: 'warning.light' }}>
<Stack direction="row" alignItems="center" justifyContent="space-between">
<Stack spacing={0.5}>
<Typography variant="subtitle2">{lesson.title || 'Занятие'}</Typography>
<Typography variant="caption" color="text.secondary">{fDateTime(lesson.start_time)}</Typography>
</Stack>
<Button
variant="contained"
color="warning"
size="small"
startIcon={<Iconify icon="solar:pen-bold" width={16} />}
onClick={() => setFeedbackLesson(lesson)}
>
Оставить отзыв
</Button>
</Stack>
</Card>
))}
<FeedbackDialog
lesson={feedbackLesson}
open={!!feedbackLesson}
onClose={() => setFeedbackLesson(null)}
onSaved={load}
/>
</Stack>
);
}
// ----------------------------------------------------------------------
// 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 (
<DashboardContent>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 10 }}><CircularProgress /></Box>
</DashboardContent>
);
}
return (
<DashboardContent>
<CustomBreadcrumbs
heading={displayName}
links={[
{ name: 'Главная', href: paths.dashboard.root },
{ name: 'Ученики', href: paths.dashboard.students },
{ name: displayName },
]}
sx={{ mb: 3 }}
/>
{/* Карточка студента */}
<Card sx={{ p: 3, mb: 3 }}>
<Stack direction="row" spacing={2.5} alignItems="center">
<Avatar src={avatar} sx={{ width: 72, height: 72, fontSize: 24, bgcolor: 'primary.main' }}>
{initials(user)}
</Avatar>
<Box flex={1}>
<Typography variant="h5">{displayName}</Typography>
{user.email && <Typography variant="body2" color="text.secondary">{user.email}</Typography>}
{student?.subject && (
<Chip label={student.subject} size="small" sx={{ mt: 0.5 }} />
)}
</Box>
<Button
variant="outlined"
startIcon={<Iconify icon="eva:arrow-back-outline" />}
onClick={() => router.push(paths.dashboard.students)}
>
Назад
</Button>
</Stack>
</Card>
{/* Табы */}
<Card>
<Tabs
value={tab}
onChange={(_, v) => setTab(v)}
sx={{ px: 2, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Tab value="progress" label="Успехи" icon={<Iconify icon="solar:chart-bold" width={18} />} iconPosition="start" />
<Tab value="lessons" label="Занятия" icon={<Iconify icon="solar:calendar-bold" width={18} />} iconPosition="start" />
<Tab
value="feedback"
label={
<Stack direction="row" spacing={0.5} alignItems="center">
<span>Обратная связь</span>
{pendingCount > 0 && (
<Chip label={pendingCount} size="small" color="warning" sx={{ height: 18, fontSize: 11 }} />
)}
</Stack>
}
icon={<Iconify icon="solar:pen-bold" width={18} />}
iconPosition="start"
/>
</Tabs>
<Box sx={{ p: 3 }}>
{tab === 'progress' && <ProgressTab clientId={clientId} />}
{tab === 'lessons' && <LessonsTab clientId={clientId} />}
{tab === 'feedback' && <FeedbackTab clientId={clientId} />}
</Box>
</Card>
</DashboardContent>
);
}

View File

@ -29,7 +29,9 @@ import ListItemAvatar from '@mui/material/ListItemAvatar';
import CircularProgress from '@mui/material/CircularProgress'; 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 { resolveMediaUrl } from 'src/utils/axios';
import { import {
getStudents, getStudents,
getMyMentors, getMyMentors,
@ -43,7 +45,6 @@ import {
getMentorshipRequestsPending, getMentorshipRequestsPending,
} from 'src/utils/students-api'; } from 'src/utils/students-api';
import { CONFIG } from 'src/config-global';
import { DashboardContent } from 'src/layouts/dashboard'; import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify'; import { Iconify } from 'src/components/iconify';
@ -53,12 +54,7 @@ import { useAuthContext } from 'src/auth/hooks';
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
function avatarUrl(href) { const avatarUrl = (href) => resolveMediaUrl(href) || null;
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}`);
}
function initials(firstName, lastName) { function initials(firstName, lastName) {
return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase(); return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase();
@ -68,6 +64,7 @@ function initials(firstName, lastName) {
// MENTOR VIEWS // MENTOR VIEWS
function MentorStudentList({ onRefresh }) { function MentorStudentList({ onRefresh }) {
const router = useRouter();
const [students, setStudents] = useState([]); const [students, setStudents] = useState([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@ -129,11 +126,12 @@ function MentorStudentList({ onRefresh }) {
return ( return (
<Grid item key={s.id} xs={12} sm={6} md={4} lg={3}> <Grid item key={s.id} xs={12} sm={6} md={4} lg={3}>
<Card <Card
onClick={() => router.push(paths.dashboard.studentDetail(s.id))}
sx={{ sx={{
p: 3, p: 3,
textAlign: 'center', textAlign: 'center',
height: '100%', height: '100%',
cursor: 'default', cursor: 'pointer',
transition: 'box-shadow 0.2s, transform 0.2s', transition: 'box-shadow 0.2s, transform 0.2s',
'&:hover': { '&:hover': {
transform: 'translateY(-2px)', transform: 'translateY(-2px)',

View File

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

View File

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