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:
parent
7cf7a78326
commit
f6caa7df6b
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
|
@ -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}
|
</Box>
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (noChildren) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 1 }}>
|
||||||
<Typography color="text.secondary">Неизвестная роль: {user.role}</Typography>
|
<Typography variant="h6" color="text.secondary">Нет привязанных детей</Typography>
|
||||||
|
<Typography variant="body2" color="text.disabled">
|
||||||
|
Обратитесь к администратору для привязки аккаунта ребёнка
|
||||||
|
</Typography>
|
||||||
</Box>
|
</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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 },
|
||||||
|
] : []),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
}),
|
}),
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 }}>Успеваемость (1–5)</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 }}>Школьная оценка (1–5)</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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export { LessonDetailView } from './lesson-detail-view';
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 =
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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="Оценка (0–100)"
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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)',
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue