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 useSWR, { mutate } from 'swr';
|
||||
|
||||
|
|
@ -10,11 +10,14 @@ import {
|
|||
updateCalendarLesson,
|
||||
deleteCalendarLesson,
|
||||
} from 'src/utils/dashboard-api';
|
||||
import { getGroups } from 'src/utils/groups-api';
|
||||
import { useAuthContext } from 'src/auth/hooks';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const STUDENTS_ENDPOINT = '/manage/clients/?page=1&page_size=200';
|
||||
const SUBJECTS_ENDPOINT = '/schedule/subjects/';
|
||||
const GROUPS_ENDPOINT = '/groups/';
|
||||
|
||||
const swrOptions = {
|
||||
revalidateIfStale: true,
|
||||
|
|
@ -36,9 +39,25 @@ export function useGetEvents(currentDate) {
|
|||
const start = format(startOfMonth(subMonths(date, 1)), 'yyyy-MM-dd');
|
||||
const end = format(endOfMonth(addMonths(date, 1)), 'yyyy-MM-dd');
|
||||
|
||||
const { user } = useAuthContext();
|
||||
|
||||
const getChildId = () => {
|
||||
if (user?.role !== 'parent') return null;
|
||||
try { const s = localStorage.getItem('selected_child'); return s ? (JSON.parse(s)?.id || null) : null; } catch { return null; }
|
||||
};
|
||||
const [childId, setChildId] = useState(getChildId);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.role !== 'parent') return undefined;
|
||||
const handler = () => setChildId(getChildId());
|
||||
window.addEventListener('child-changed', handler);
|
||||
return () => window.removeEventListener('child-changed', handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [user?.role]);
|
||||
|
||||
const { data: response, isLoading, error, isValidating } = useSWR(
|
||||
['calendar', start, end],
|
||||
([, s, e]) => getCalendarLessons(s, e),
|
||||
['calendar', start, end, childId],
|
||||
([, s, e, cid]) => getCalendarLessons(s, e, cid ? { child_id: cid } : undefined),
|
||||
swrOptions
|
||||
);
|
||||
|
||||
|
|
@ -58,8 +77,10 @@ export function useGetEvents(currentDate) {
|
|||
: '';
|
||||
|
||||
const subject = lesson.subject_name || lesson.subject || 'Урок';
|
||||
const student = lesson.client_name || '';
|
||||
const displayTitle = `${startTimeStr} ${subject}${student ? ` - ${student}` : ''}`;
|
||||
const participant = lesson.group_name
|
||||
? `Группа: ${lesson.group_name}`
|
||||
: (lesson.client_name || '');
|
||||
const displayTitle = `${startTimeStr} ${subject}${participant ? ` - ${participant}` : ''}`;
|
||||
|
||||
const status = String(lesson.status || 'scheduled').toLowerCase();
|
||||
let eventColor = '#7635dc';
|
||||
|
|
@ -82,6 +103,8 @@ export function useGetEvents(currentDate) {
|
|||
status,
|
||||
student: lesson.client_name || '',
|
||||
mentor: lesson.mentor_name || '',
|
||||
group: lesson.group || null,
|
||||
group_name: lesson.group_name || '',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -134,6 +157,16 @@ export function useGetSubjects() {
|
|||
}, [response, isLoading, error]);
|
||||
}
|
||||
|
||||
export function useGetGroups() {
|
||||
const { data, isLoading, error } = useSWR(GROUPS_ENDPOINT, getGroups, swrOptions);
|
||||
|
||||
return useMemo(() => ({
|
||||
groups: Array.isArray(data) ? data : [],
|
||||
groupsLoading: isLoading,
|
||||
groupsError: error,
|
||||
}), [data, isLoading, error]);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function revalidateCalendar(date) {
|
||||
|
|
@ -144,18 +177,16 @@ function revalidateCalendar(date) {
|
|||
}
|
||||
|
||||
export async function createEvent(eventData, currentDate) {
|
||||
const startTime = new Date(eventData.start_time);
|
||||
const endTime = new Date(startTime.getTime() + (eventData.duration || 60) * 60000);
|
||||
|
||||
const isGroup = !!eventData.group;
|
||||
const payload = {
|
||||
client: String(eventData.client),
|
||||
title: eventData.title || 'Занятие',
|
||||
description: eventData.description || '',
|
||||
start_time: startTime.toISOString(),
|
||||
end_time: endTime.toISOString(),
|
||||
start_time: eventData.start_time,
|
||||
duration: eventData.duration || 60,
|
||||
price: eventData.price,
|
||||
is_recurring: eventData.is_recurring || false,
|
||||
...(eventData.subject && { subject_id: Number(eventData.subject) }),
|
||||
...(isGroup ? { group: eventData.group } : { client: String(eventData.client) }),
|
||||
};
|
||||
|
||||
const res = await createCalendarLesson(payload);
|
||||
|
|
@ -167,12 +198,8 @@ export async function updateEvent(eventData, currentDate) {
|
|||
const { id, ...data } = eventData;
|
||||
|
||||
const updatePayload = {};
|
||||
if (data.start_time) {
|
||||
const startTime = new Date(data.start_time);
|
||||
const endTime = new Date(startTime.getTime() + (data.duration || 60) * 60000);
|
||||
updatePayload.start_time = startTime.toISOString();
|
||||
updatePayload.end_time = endTime.toISOString();
|
||||
}
|
||||
if (data.start_time) updatePayload.start_time = data.start_time;
|
||||
if (data.duration) updatePayload.duration = data.duration;
|
||||
if (data.price != null) updatePayload.price = data.price;
|
||||
if (data.description != null) updatePayload.description = data.description;
|
||||
if (data.status) updatePayload.status = data.status;
|
||||
|
|
|
|||
|
|
@ -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 Box from '@mui/material/Box';
|
||||
|
|
@ -6,29 +5,82 @@ import Typography from '@mui/material/Typography';
|
|||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
|
||||
import { useAuthContext } from 'src/auth/hooks';
|
||||
import axios from 'src/utils/axios';
|
||||
|
||||
import { OverviewCourseView } from 'src/sections/overview/course/view';
|
||||
import { OverviewClientView } from 'src/sections/overview/client/view';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
async function loadChildren() {
|
||||
try {
|
||||
const res = await axios.get('/parent/dashboard/');
|
||||
const raw = res.data?.children ?? [];
|
||||
return raw.map((item) => {
|
||||
const c = item.child ?? item;
|
||||
return { id: c.id, name: c.name || c.email || '' };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { user, loading } = useAuthContext();
|
||||
|
||||
// Для родителя: выбранный ребёнок из localStorage
|
||||
const [selectedChild, setSelectedChild] = useState(null);
|
||||
const [childrenLoading, setChildrenLoading] = useState(false);
|
||||
const [noChildren, setNoChildren] = useState(false);
|
||||
|
||||
// Load children for parent role
|
||||
useEffect(() => {
|
||||
if (user?.role === 'parent') {
|
||||
if (user?.role !== 'parent') return undefined;
|
||||
|
||||
setChildrenLoading(true);
|
||||
|
||||
loadChildren().then((list) => {
|
||||
setChildrenLoading(false);
|
||||
|
||||
if (!list.length) {
|
||||
setNoChildren(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to restore saved child
|
||||
try {
|
||||
const saved = localStorage.getItem('selected_child');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
const exists = list.find((c) => c.id === parsed.id);
|
||||
if (exists) {
|
||||
setSelectedChild(parsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Auto-select first child
|
||||
const first = list[0];
|
||||
localStorage.setItem('selected_child', JSON.stringify(first));
|
||||
window.dispatchEvent(new Event('child-changed'));
|
||||
setSelectedChild(first);
|
||||
});
|
||||
|
||||
// React to child switch from nav selector
|
||||
const handler = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('selected_child');
|
||||
if (saved) setSelectedChild(JSON.parse(saved));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
window.addEventListener('child-changed', handler);
|
||||
return () => window.removeEventListener('child-changed', handler);
|
||||
}, [user]);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||
|
|
@ -39,26 +91,40 @@ export default function DashboardPage() {
|
|||
|
||||
if (!user) return null;
|
||||
|
||||
if (user.role === 'mentor') {
|
||||
return <OverviewCourseView />;
|
||||
}
|
||||
if (user.role === 'mentor') return <OverviewCourseView />;
|
||||
|
||||
if (user.role === 'client') {
|
||||
return <OverviewClientView />;
|
||||
}
|
||||
if (user.role === 'client') return <OverviewClientView />;
|
||||
|
||||
if (user.role === 'parent') {
|
||||
if (childrenLoading) {
|
||||
return (
|
||||
<OverviewClientView
|
||||
childId={selectedChild?.id || null}
|
||||
childName={selectedChild?.name || null}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (noChildren) {
|
||||
return (
|
||||
<Box sx={{ p: 3, textAlign: 'center' }}>
|
||||
<Typography color="text.secondary">Неизвестная роль: {user.role}</Typography>
|
||||
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography variant="h6" color="text.secondary">Нет привязанных детей</Typography>
|
||||
<Typography variant="body2" color="text.disabled">
|
||||
Обратитесь к администратору для привязки аккаунта ребёнка
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (!selectedChild) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '80vh', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return <OverviewClientView childId={selectedChild.id} childName={selectedChild.name} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,26 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Menu from '@mui/material/Menu';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Button from '@mui/material/Button';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import { paths } from 'src/routes/paths';
|
||||
|
||||
import { CONFIG } from 'src/config-global';
|
||||
import { useAuthContext } from 'src/auth/hooks';
|
||||
import { signOut } from 'src/auth/context/jwt/action';
|
||||
import axios from 'src/utils/axios';
|
||||
|
||||
import { Label } from 'src/components/label';
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
|
|
@ -29,16 +39,174 @@ async function fetchActiveSubscription() {
|
|||
}
|
||||
}
|
||||
|
||||
async function fetchChildren() {
|
||||
try {
|
||||
const res = await axios.get('/parent/dashboard/');
|
||||
const raw = res.data?.children ?? [];
|
||||
// Normalize: { child: {id, name, email} } → { id, first_name, last_name, email }
|
||||
return raw.map((item) => {
|
||||
const c = item.child ?? item;
|
||||
const [first_name = '', ...rest] = (c.name || '').split(' ');
|
||||
return { id: c.id, first_name, last_name: rest.join(' '), email: c.email };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function ChildSelector({ children }) {
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [anchorEl, setAnchorEl] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!children.length) return;
|
||||
try {
|
||||
const saved = localStorage.getItem('selected_child');
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
const exists = children.find((c) => c.id === parsed.id);
|
||||
if (exists) { setSelected(parsed); return; }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const first = children[0];
|
||||
const child = {
|
||||
id: first.id,
|
||||
name: `${first.first_name || ''} ${first.last_name || ''}`.trim() || first.email,
|
||||
};
|
||||
localStorage.setItem('selected_child', JSON.stringify(child));
|
||||
window.dispatchEvent(new Event('child-changed'));
|
||||
setSelected(child);
|
||||
}, [children]);
|
||||
|
||||
const handleSelect = (child) => {
|
||||
const data = {
|
||||
id: child.id,
|
||||
name: `${child.first_name || ''} ${child.last_name || ''}`.trim() || child.email,
|
||||
};
|
||||
localStorage.setItem('selected_child', JSON.stringify(data));
|
||||
window.dispatchEvent(new Event('child-changed'));
|
||||
setSelected(data);
|
||||
setAnchorEl(null);
|
||||
};
|
||||
|
||||
if (!selected) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 }) {
|
||||
const { user } = useAuthContext();
|
||||
const [sub, setSub] = useState(undefined); // undefined = loading, null = no sub
|
||||
const { user, checkUserSession } = useAuthContext();
|
||||
const router = useRouter();
|
||||
|
||||
const handleLogout = useCallback(async () => {
|
||||
await signOut();
|
||||
await checkUserSession();
|
||||
router.push(paths.auth.jwt.signIn);
|
||||
}, [checkUserSession, router]);
|
||||
|
||||
const [sub, setSub] = useState(undefined);
|
||||
const [children, setChildren] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id) return;
|
||||
fetchActiveSubscription().then(setSub);
|
||||
}, [user?.id]);
|
||||
if (user.role !== 'client') fetchActiveSubscription().then(setSub);
|
||||
if (user.role === 'parent') fetchChildren().then(setChildren);
|
||||
}, [user?.id, user?.role]);
|
||||
|
||||
const displayName = user
|
||||
? `${user.first_name || ''} ${user.last_name || ''}`.trim() || user.email
|
||||
|
|
@ -55,33 +223,27 @@ export function NavUpgrade({ sx, ...other }) {
|
|||
: `${(CONFIG.site.serverUrl || '').replace(/\/api\/?$/, '')}${user.avatar}`
|
||||
: null;
|
||||
|
||||
// Subscription label
|
||||
let labelColor = 'default';
|
||||
let labelText = 'Нет подписки';
|
||||
|
||||
if (sub === undefined) {
|
||||
labelText = '…';
|
||||
} else if (sub && sub.is_active_now) {
|
||||
const planName = sub.plan?.name || 'Подписка';
|
||||
const status = sub.status;
|
||||
labelText = status === 'trial' ? `Пробный: ${planName}` : planName;
|
||||
labelColor = status === 'trial' ? 'warning' : 'success';
|
||||
labelText = sub.status === 'trial' ? `Пробный: ${planName}` : planName;
|
||||
labelColor = sub.status === 'trial' ? 'warning' : 'success';
|
||||
}
|
||||
|
||||
// End date display
|
||||
let endDateText = null;
|
||||
if (sub && sub.is_active_now) {
|
||||
const endField = sub.status === 'trial' ? sub.trial_end_date : sub.end_date;
|
||||
if (endField) {
|
||||
const date = new Date(endField);
|
||||
endDateText = `до ${date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })}`;
|
||||
endDateText = `до ${new Date(endField).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', year: 'numeric' })}`;
|
||||
}
|
||||
if (sub.days_left !== undefined && sub.days_left !== null) {
|
||||
if (sub.days_left != null) {
|
||||
endDateText = endDateText ? `${endDateText} (${sub.days_left} дн.)` : `${sub.days_left} дн.`;
|
||||
}
|
||||
}
|
||||
|
||||
// Progress bar for days left (out of 30)
|
||||
const daysProgress =
|
||||
sub?.days_left != null && sub?.is_active_now
|
||||
? Math.min(100, Math.round((sub.days_left / 30) * 100))
|
||||
|
|
@ -94,12 +256,18 @@ export function NavUpgrade({ sx, ...other }) {
|
|||
py: 2.5,
|
||||
borderTop: '1px solid',
|
||||
borderColor: 'var(--layout-nav-border-color)',
|
||||
gap: 1.5,
|
||||
...sx,
|
||||
}}
|
||||
{...other}
|
||||
>
|
||||
{/* Селектор ребёнка — только для родителя, над профилем */}
|
||||
{user?.role === 'parent' && children.length > 0 && (
|
||||
<ChildSelector children={children} />
|
||||
)}
|
||||
|
||||
{/* 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 }}>
|
||||
<Avatar
|
||||
src={avatarSrc}
|
||||
|
|
@ -128,7 +296,8 @@ export function NavUpgrade({ sx, ...other }) {
|
|||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Subscription badge */}
|
||||
{/* Subscription badge — только для ментора и родителя */}
|
||||
{user?.role !== 'client' && (
|
||||
<Stack spacing={0.75}>
|
||||
<Stack direction="row" alignItems="center" justifyContent="space-between">
|
||||
<Label color={labelColor} variant="soft" sx={{ fontSize: 11 }}>
|
||||
|
|
@ -144,7 +313,6 @@ export function NavUpgrade({ sx, ...other }) {
|
|||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{daysProgress !== null && (
|
||||
<Tooltip title={`Осталось ${sub.days_left} дн.`} placement="top">
|
||||
<LinearProgress
|
||||
|
|
@ -156,12 +324,24 @@ export function NavUpgrade({ sx, ...other }) {
|
|||
</Tooltip>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Button
|
||||
fullWidth
|
||||
size="small"
|
||||
color="error"
|
||||
variant="soft"
|
||||
onClick={handleLogout}
|
||||
startIcon={<Iconify icon="solar:logout-2-bold-duotone" width={18} />}
|
||||
>
|
||||
Выйти
|
||||
</Button>
|
||||
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// UpgradeBlock — оставляем для совместимости, больше не используется в платформе
|
||||
export function UpgradeBlock({ 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 ICONS = {
|
||||
chat: icon('ic-chat'),
|
||||
user: icon('ic-user'),
|
||||
course: icon('ic-course'),
|
||||
calendar: icon('ic-calendar'),
|
||||
dashboard: icon('ic-dashboard'),
|
||||
kanban: icon('ic-kanban'),
|
||||
user: icon('ic-user'),
|
||||
calendar: icon('ic-calendar'),
|
||||
booking: icon('ic-booking'),
|
||||
folder: icon('ic-folder'),
|
||||
kanban: icon('ic-kanban'),
|
||||
chat: icon('ic-chat'),
|
||||
mail: icon('ic-mail'),
|
||||
analytics: icon('ic-analytics'),
|
||||
course: icon('ic-course'),
|
||||
label: icon('ic-label'),
|
||||
banking: icon('ic-banking'),
|
||||
invoice: icon('ic-invoice'),
|
||||
tour: icon('ic-tour'),
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
|
@ -35,40 +40,52 @@ export function getNavData(role) {
|
|||
{
|
||||
subheader: 'Инструменты',
|
||||
items: [
|
||||
// Ученики/Менторы — для всех ролей (разный контент внутри)
|
||||
// Ученики/Менторы — только для ментора и клиента (не для родителя)
|
||||
...(!isParent ? [
|
||||
{ title: isMentor ? 'Ученики' : 'Менторы', path: paths.dashboard.students, icon: ICONS.user },
|
||||
] : []),
|
||||
{ title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar },
|
||||
{ title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban },
|
||||
{ title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.booking },
|
||||
{ title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder },
|
||||
{ title: 'Доска', path: paths.dashboard.board, icon: ICONS.kanban },
|
||||
...(!isParent ? [{ title: 'Доска', path: paths.dashboard.board, icon: ICONS.kanban }] : []),
|
||||
{ title: 'Чат', path: paths.dashboard.chatPlatform, icon: ICONS.chat },
|
||||
{ title: 'Уведомления', path: paths.dashboard.notifications, icon: ICONS.label },
|
||||
{ title: 'Уведомления', path: paths.dashboard.notifications, icon: ICONS.mail },
|
||||
|
||||
// Ментор-специфичные
|
||||
...(isMentor ? [
|
||||
{ title: 'Группы', path: paths.dashboard.groups, icon: ICONS.tour },
|
||||
{ title: 'Аналитика', path: paths.dashboard.analytics, icon: ICONS.analytics },
|
||||
{ title: 'Обратная связь', path: paths.dashboard.feedback, icon: icon('ic-label') },
|
||||
{ title: 'Обратная связь', path: paths.dashboard.feedback, icon: ICONS.label },
|
||||
] : []),
|
||||
|
||||
// Клиент/Родитель
|
||||
...((isClient || isParent) ? [
|
||||
// Клиент
|
||||
...(isClient ? [
|
||||
{ title: 'Мои группы', path: paths.dashboard.groups, icon: ICONS.tour },
|
||||
{ title: 'Мой прогресс', path: paths.dashboard.myProgress, icon: ICONS.course },
|
||||
] : []),
|
||||
|
||||
// Родитель
|
||||
...(isParent ? [
|
||||
{ title: 'Прогресс', path: paths.dashboard.myProgress, icon: ICONS.course },
|
||||
] : []),
|
||||
|
||||
// Родитель-специфичные
|
||||
...(isParent ? [
|
||||
{ title: 'Дети', path: paths.dashboard.children, icon: ICONS.user },
|
||||
{ title: 'Прогресс детей', path: paths.dashboard.childrenProgress, icon: ICONS.course },
|
||||
] : []),
|
||||
|
||||
{ title: 'Оплата', path: paths.dashboard.payment, icon: ICONS.folder },
|
||||
...(isMentor ? [
|
||||
{ title: 'Оплата', path: paths.dashboard.payment, icon: ICONS.banking },
|
||||
] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
subheader: 'Аккаунт',
|
||||
items: [
|
||||
{ title: 'Профиль', path: paths.dashboard.profile, icon: ICONS.user },
|
||||
{ title: 'Рефералы', path: paths.dashboard.referrals, icon: ICONS.course },
|
||||
...(isMentor ? [
|
||||
{ title: 'Рефералы', path: paths.dashboard.referrals, icon: ICONS.invoice },
|
||||
] : []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function LayoutSection({
|
|||
<>
|
||||
{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}
|
||||
|
|
@ -42,6 +42,8 @@ export function LayoutSection({
|
|||
display="flex"
|
||||
flex="1 1 auto"
|
||||
flexDirection="column"
|
||||
minHeight={0}
|
||||
overflow="auto"
|
||||
className={layoutClasses.hasSidebar}
|
||||
>
|
||||
{headerSection}
|
||||
|
|
|
|||
|
|
@ -161,12 +161,13 @@ export function DashboardLayout({ sx, children, data }) {
|
|||
isNavMini={isNavMini}
|
||||
layoutQuery={layoutQuery}
|
||||
cssVars={navColorVars.section}
|
||||
onToggleNav={() =>
|
||||
onToggleNav={() => {
|
||||
settings.onUpdateField(
|
||||
'navLayout',
|
||||
settings.navLayout === 'vertical' ? 'mini' : 'vertical'
|
||||
)
|
||||
}
|
||||
);
|
||||
setTimeout(() => window.dispatchEvent(new Event('resize')), 0);
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ export function Main({ children, isNavHorizontal, sx, ...other }) {
|
|||
display: 'flex',
|
||||
flex: '1 1 auto',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
overflow: 'auto',
|
||||
...(isNavHorizontal && {
|
||||
'--layout-dashboard-content-pt': '40px',
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -25,8 +25,10 @@ export const paths = {
|
|||
root: ROOTS.DASHBOARD,
|
||||
calendar: `${ROOTS.DASHBOARD}/schedule`,
|
||||
homework: `${ROOTS.DASHBOARD}/homework`,
|
||||
homeworkDetail: (id) => `${ROOTS.DASHBOARD}/homework/${id}`,
|
||||
materials: `${ROOTS.DASHBOARD}/materials`,
|
||||
students: `${ROOTS.DASHBOARD}/students`,
|
||||
studentDetail: (id) => `${ROOTS.DASHBOARD}/students/${id}`,
|
||||
notifications: `${ROOTS.DASHBOARD}/notifications`,
|
||||
board: `${ROOTS.DASHBOARD}/board`,
|
||||
chatPlatform: `${ROOTS.DASHBOARD}/chat-platform`,
|
||||
|
|
@ -38,5 +40,9 @@ export const paths = {
|
|||
children: `${ROOTS.DASHBOARD}/children`,
|
||||
childrenProgress: `${ROOTS.DASHBOARD}/children-progress`,
|
||||
myProgress: `${ROOTS.DASHBOARD}/my-progress`,
|
||||
prejoin: `${ROOTS.DASHBOARD}/prejoin`,
|
||||
lesson: (id) => `${ROOTS.DASHBOARD}/lesson/${id}`,
|
||||
groups: `${ROOTS.DASHBOARD}/groups`,
|
||||
groupDetail: (id) => `${ROOTS.DASHBOARD}/groups/${id}`,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { lazy, Suspense } from 'react';
|
||||
import { Navigate, useRoutes, Outlet } from 'react-router-dom';
|
||||
import { Navigate, useRoutes, useParams, Outlet } from 'react-router-dom';
|
||||
|
||||
import { AuthGuard } from 'src/auth/guard/auth-guard';
|
||||
import { GuestGuard } from 'src/auth/guard/guest-guard';
|
||||
|
|
@ -52,6 +52,9 @@ const MaterialsView = lazy(() =>
|
|||
const StudentsView = lazy(() =>
|
||||
import('src/sections/students/view').then((m) => ({ default: m.StudentsView }))
|
||||
);
|
||||
const StudentDetailView = lazy(() =>
|
||||
import('src/sections/students/view/student-detail-view').then((m) => ({ default: m.StudentDetailView }))
|
||||
);
|
||||
const NotificationsView = lazy(() =>
|
||||
import('src/sections/notifications/view').then((m) => ({ default: m.NotificationsView }))
|
||||
);
|
||||
|
|
@ -85,6 +88,18 @@ const ChildrenProgressView = lazy(() =>
|
|||
const MyProgressView = lazy(() =>
|
||||
import('src/sections/my-progress/view').then((m) => ({ default: m.MyProgressView }))
|
||||
);
|
||||
const LessonDetailView = lazy(() =>
|
||||
import('src/sections/lesson-detail/view').then((m) => ({ default: m.LessonDetailView }))
|
||||
);
|
||||
const HomeworkDetailView = lazy(() =>
|
||||
import('src/sections/homework/view').then((m) => ({ default: m.HomeworkDetailView }))
|
||||
);
|
||||
const GroupsView = lazy(() =>
|
||||
import('src/sections/groups/view').then((m) => ({ default: m.GroupsView }))
|
||||
);
|
||||
const GroupDetailView = lazy(() =>
|
||||
import('src/sections/groups/view').then((m) => ({ default: m.GroupDetailView }))
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Video call (fullscreen, no dashboard layout)
|
||||
|
|
@ -93,6 +108,10 @@ const VideoCallView = lazy(() =>
|
|||
import('src/sections/video-call/view').then((m) => ({ default: m.VideoCallView }))
|
||||
);
|
||||
|
||||
const PrejoinView = lazy(() =>
|
||||
import('src/sections/prejoin/view/prejoin-view').then((m) => ({ default: m.PrejoinView }))
|
||||
);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// Error pages
|
||||
|
||||
|
|
@ -126,6 +145,16 @@ function AuthLayoutWrapper() {
|
|||
);
|
||||
}
|
||||
|
||||
function LessonDetailWrapper() {
|
||||
const { id } = useParams();
|
||||
return <LessonDetailView id={id} />;
|
||||
}
|
||||
|
||||
function HomeworkDetailWrapper() {
|
||||
const { id } = useParams();
|
||||
return <HomeworkDetailView id={id} />;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
export function Router() {
|
||||
|
|
@ -166,6 +195,7 @@ export function Router() {
|
|||
{ path: 'homework', element: <S><HomeworkView /></S> },
|
||||
{ path: 'materials', element: <S><MaterialsView /></S> },
|
||||
{ path: 'students', element: <S><StudentsView /></S> },
|
||||
{ path: 'students/:clientId', element: <S><StudentDetailView /></S> },
|
||||
{ path: 'notifications', element: <S><NotificationsView /></S> },
|
||||
{ path: 'board', element: <S><BoardView /></S> },
|
||||
{ path: 'chat-platform', element: <S><ChatPlatformView /></S> },
|
||||
|
|
@ -177,6 +207,11 @@ export function Router() {
|
|||
{ path: 'children', element: <S><ChildrenView /></S> },
|
||||
{ path: 'children-progress', element: <S><ChildrenProgressView /></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 axios, { resolveMediaUrl } from 'src/utils/axios';
|
||||
import { unlinkTelegram, getTelegramStatus, getTelegramBotInfo, generateTelegramCode } from 'src/utils/telegram-api';
|
||||
import {
|
||||
searchCities,
|
||||
|
|
@ -46,7 +47,6 @@ import {
|
|||
updateNotificationPreferences,
|
||||
} from 'src/utils/profile-api';
|
||||
|
||||
import { CONFIG } from 'src/config-global';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
|
@ -87,12 +87,7 @@ const CHANNELS = [
|
|||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function avatarSrc(src) {
|
||||
if (!src) return '';
|
||||
if (src.startsWith('http://') || src.startsWith('https://')) return src;
|
||||
const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
|
||||
return base + (src.startsWith('/') ? src : `/${src}`);
|
||||
}
|
||||
const avatarSrc = (src) => resolveMediaUrl(src);
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
|
|
@ -322,6 +317,154 @@ function TelegramSection({ onAvatarLoaded }) {
|
|||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const CHILD_NOTIFICATION_TYPES = [
|
||||
{ value: 'lesson_created', label: 'Создано занятие' },
|
||||
{ value: 'lesson_updated', label: 'Занятие обновлено' },
|
||||
{ value: 'lesson_cancelled', label: 'Занятие отменено' },
|
||||
{ value: 'lesson_rescheduled', label: 'Занятие перенесено' },
|
||||
{ value: 'lesson_reminder', label: 'Напоминание о занятии' },
|
||||
{ value: 'lesson_completed', label: 'Занятие завершено' },
|
||||
{ value: 'homework_assigned', label: 'Назначено домашнее задание' },
|
||||
{ value: 'homework_submitted', label: 'ДЗ сдано' },
|
||||
{ value: 'homework_reviewed', label: 'ДЗ проверено' },
|
||||
{ value: 'homework_returned', label: 'ДЗ возвращено на доработку' },
|
||||
{ value: 'homework_deadline_reminder', label: 'Напоминание о дедлайне ДЗ' },
|
||||
{ value: 'material_added', label: 'Добавлен материал' },
|
||||
];
|
||||
|
||||
function ParentChildNotifications() {
|
||||
const [children, setChildren] = useState([]);
|
||||
const [settings, setSettings] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState({});
|
||||
const [expanded, setExpanded] = useState(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await axios.get('/parent/dashboard/');
|
||||
const raw = res.data?.children ?? [];
|
||||
const list = raw.map((item) => {
|
||||
const c = item.child ?? item;
|
||||
return { id: String(c.id), name: c.name || c.email || 'Ребёнок', avatar_url: c.avatar_url };
|
||||
});
|
||||
setChildren(list);
|
||||
|
||||
const map = {};
|
||||
await Promise.all(list.map(async (child) => {
|
||||
try {
|
||||
const r = await axios.get(`/notifications/parent-child-settings/for_child/?child_id=${child.id}`);
|
||||
map[child.id] = r.data;
|
||||
} catch {
|
||||
map[child.id] = { enabled: true, type_settings: {} };
|
||||
}
|
||||
}));
|
||||
setSettings(map);
|
||||
} catch {
|
||||
setError('Не удалось загрузить настройки уведомлений для детей');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, []);
|
||||
|
||||
const patch = async (childId, payload) => {
|
||||
try {
|
||||
setSaving((p) => ({ ...p, [childId]: true }));
|
||||
const r = await axios.patch(`/notifications/parent-child-settings/for_child/?child_id=${childId}`, payload);
|
||||
setSettings((p) => ({ ...p, [childId]: r.data }));
|
||||
} catch {
|
||||
setError('Не удалось сохранить');
|
||||
} finally {
|
||||
setSaving((p) => ({ ...p, [childId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <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 }) {
|
||||
const visibleTypes = role === 'parent'
|
||||
? NOTIFICATION_TYPES.filter((t) => !PARENT_EXCLUDED_TYPES.includes(t.key))
|
||||
|
|
@ -395,7 +538,6 @@ export function AccountPlatformView() {
|
|||
// Profile fields
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [avatarPreview, setAvatarPreview] = useState(null);
|
||||
const [avatarHovered, setAvatarHovered] = useState(false);
|
||||
|
|
@ -429,7 +571,6 @@ export function AccountPlatformView() {
|
|||
if (user) {
|
||||
setFirstName(user.first_name || '');
|
||||
setLastName(user.last_name || '');
|
||||
setPhone(user.phone || '');
|
||||
setEmail(user.email || '');
|
||||
}
|
||||
}, [user]);
|
||||
|
|
@ -710,13 +851,6 @@ export function AccountPlatformView() {
|
|||
fullWidth
|
||||
/>
|
||||
</Stack>
|
||||
<TextField
|
||||
label="Телефон"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
onBlur={(e) => handleProfileBlur('phone', e.target.value.trim())}
|
||||
fullWidth
|
||||
/>
|
||||
<TextField
|
||||
label="Email"
|
||||
value={email}
|
||||
|
|
@ -909,6 +1043,9 @@ export function AccountPlatformView() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Per-child notification settings — parent only */}
|
||||
{user?.role === 'parent' && <ParentChildNotifications />}
|
||||
|
||||
{/* AI homework settings (mentor only) */}
|
||||
{user?.role === 'mentor' && settings && (
|
||||
<Card>
|
||||
|
|
|
|||
|
|
@ -7,18 +7,37 @@ import listPlugin from '@fullcalendar/list';
|
|||
import timelinePlugin from '@fullcalendar/timeline';
|
||||
import ruLocale from '@fullcalendar/core/locales/ru';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Alert from '@mui/material/Alert';
|
||||
import Avatar from '@mui/material/Avatar';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import Switch from '@mui/material/Switch';
|
||||
import Rating from '@mui/material/Rating';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Container from '@mui/material/Container';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import FormControlLabel from '@mui/material/FormControlLabel';
|
||||
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import { useAuthContext } from 'src/auth/hooks';
|
||||
import { useBoolean } from 'src/hooks/use-boolean';
|
||||
import { useGetEvents, updateEvent, createEvent, deleteEvent } from 'src/actions/calendar';
|
||||
import { createLiveKitRoom } from 'src/utils/livekit-api';
|
||||
import { completeLesson, uploadLessonFile } from 'src/utils/dashboard-api';
|
||||
import { createHomework } from 'src/utils/homework-api';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
||||
|
|
@ -31,15 +50,466 @@ import { useCalendar } from '../hooks/use-calendar';
|
|||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const MAX_HW_FILES = 10;
|
||||
const MAX_HW_FILE_MB = 10;
|
||||
|
||||
function defaultDeadline() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + 10);
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
// Controlled: lessonId + open + onClose props.
|
||||
// Uncontrolled: reads lessonId from sessionStorage on mount.
|
||||
function CompleteLessonDialog({ lessonId: propLessonId, open: propOpen, onClose: propOnClose }) {
|
||||
const isControlled = propLessonId != null;
|
||||
|
||||
const [ssLessonId, setSsLessonId] = useState(null);
|
||||
const [ssOpen, setSsOpen] = useState(false);
|
||||
|
||||
const [mentorGrade, setMentorGrade] = useState(0);
|
||||
const [schoolGrade, setSchoolGrade] = useState(0);
|
||||
const [notes, setNotes] = useState('');
|
||||
const [hasHw, setHasHw] = useState(false);
|
||||
const [hwTitle, setHwTitle] = useState('Домашнее задание');
|
||||
const [hwText, setHwText] = useState('');
|
||||
const [hwFiles, setHwFiles] = useState([]);
|
||||
const [hwDeadline, setHwDeadline] = useState(defaultDeadline);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const fileInputRef = useRef(null);
|
||||
|
||||
// Uncontrolled: check sessionStorage once on mount
|
||||
useEffect(() => {
|
||||
if (isControlled) return;
|
||||
try {
|
||||
const id = sessionStorage.getItem('complete_lesson_id');
|
||||
if (id) {
|
||||
setSsLessonId(parseInt(id, 10));
|
||||
setSsOpen(true);
|
||||
sessionStorage.removeItem('complete_lesson_id');
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Reset form when a new lesson is opened
|
||||
useEffect(() => {
|
||||
const willOpen = isControlled ? propOpen : ssOpen;
|
||||
if (willOpen) {
|
||||
setMentorGrade(0);
|
||||
setSchoolGrade(0);
|
||||
setNotes('');
|
||||
setHasHw(false);
|
||||
setHwTitle('Домашнее задание');
|
||||
setHwText('');
|
||||
setHwFiles([]);
|
||||
setHwDeadline(defaultDeadline());
|
||||
setError(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isControlled ? propOpen : ssOpen, isControlled ? propLessonId : ssLessonId]);
|
||||
|
||||
const lessonId = isControlled ? propLessonId : ssLessonId;
|
||||
const open = isControlled ? (propOpen ?? false) : ssOpen;
|
||||
|
||||
const handleClose = () => {
|
||||
if (loading) return;
|
||||
if (isControlled) propOnClose?.();
|
||||
else setSsOpen(false);
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const { files: list } = e.target;
|
||||
if (!list?.length) return;
|
||||
const toAdd = Array.from(list).filter((f) => f.size <= MAX_HW_FILE_MB * 1024 * 1024);
|
||||
setHwFiles((prev) => [...prev, ...toAdd].slice(0, MAX_HW_FILES));
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!lessonId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const mg = mentorGrade > 0 ? mentorGrade : undefined;
|
||||
const sg = schoolGrade > 0 ? schoolGrade : undefined;
|
||||
const n = notes.trim() || '';
|
||||
|
||||
let fileIds = [];
|
||||
if (hasHw && hwFiles.length > 0) {
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
for (const file of hwFiles) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const created = await uploadLessonFile(lessonId, file);
|
||||
const fid = typeof created.id === 'number' ? created.id : parseInt(String(created.id), 10);
|
||||
if (!Number.isNaN(fid)) fileIds.push(fid);
|
||||
}
|
||||
}
|
||||
|
||||
const hwT = hasHw ? hwText.trim() || undefined : undefined;
|
||||
await completeLesson(String(lessonId), n, mg, sg, hwT, hasHw && fileIds.length > 0, fileIds.length > 0 ? fileIds : undefined);
|
||||
|
||||
if (hasHw && (hwText.trim() || hwFiles.length > 0)) {
|
||||
await createHomework({
|
||||
title: hwTitle.trim() || 'Домашнее задание',
|
||||
description: hwText.trim(),
|
||||
lesson_id: lessonId,
|
||||
due_date: hwDeadline,
|
||||
status: 'published',
|
||||
});
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.detail || e?.message || 'Не удалось сохранить данные урока');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<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() {
|
||||
const settings = useSettingsContext();
|
||||
const router = useRouter();
|
||||
const { user } = useAuthContext();
|
||||
const isMentor = user?.role === 'mentor';
|
||||
|
||||
const [completeLessonId, setCompleteLessonId] = useState(null);
|
||||
const [completeLessonOpen, setCompleteLessonOpen] = useState(false);
|
||||
|
||||
const handleOpenCompleteLesson = useCallback((lessonId) => {
|
||||
setCompleteLessonId(lessonId);
|
||||
setCompleteLessonOpen(true);
|
||||
}, []);
|
||||
|
||||
const { calendarRef, view, date, onDatePrev, onDateNext, onDateToday, onChangeView,
|
||||
onSelectRange, onClickEvent, onResizeEvent, onDropEvent, onInitialView,
|
||||
openForm, onOpenForm, onCloseForm, selectEventId, selectedRange } = useCalendar();
|
||||
|
||||
// Intercept event click:
|
||||
// - completed/cancelled/ongoing → detail page
|
||||
// - past lessons (end_time already passed) → detail page regardless of status
|
||||
// - future planned → mentor gets edit form, student gets detail page
|
||||
const handleEventClick = useCallback((arg) => {
|
||||
const eventId = arg.event.id;
|
||||
if (!eventId || eventId === 'undefined' || eventId === 'null') return;
|
||||
|
||||
const ep = arg.event.extendedProps || {};
|
||||
const status = String(ep.status || '').toLowerCase();
|
||||
|
||||
// Use raw API timestamps from extendedProps (more reliable than FullCalendar Date objects)
|
||||
const rawEnd = ep.end_time || ep.end;
|
||||
const rawStart = ep.start_time || ep.start;
|
||||
const refTime = rawEnd || rawStart;
|
||||
const isPast = refTime ? new Date(refTime).getTime() < Date.now() : false;
|
||||
|
||||
if (NON_PLANNED_STATUSES.includes(status) || isPast) {
|
||||
router.push(paths.dashboard.lesson(eventId));
|
||||
} else if (isMentor) {
|
||||
onClickEvent(arg);
|
||||
} else {
|
||||
// student/parent — open detail page for upcoming lessons too
|
||||
router.push(paths.dashboard.lesson(eventId));
|
||||
}
|
||||
}, [router, isMentor, onClickEvent]);
|
||||
|
||||
const { events, eventsLoading } = useGetEvents(date);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -91,6 +561,8 @@ export function CalendarView() {
|
|||
)}
|
||||
</Stack>
|
||||
|
||||
<UpcomingLessonsBar events={events} isMentor={isMentor} timezone={user?.timezone} />
|
||||
|
||||
<Card sx={{ position: 'relative' }}>
|
||||
<StyledCalendar>
|
||||
<CalendarToolbar
|
||||
|
|
@ -113,18 +585,26 @@ export function CalendarView() {
|
|||
ref={calendarRef}
|
||||
initialDate={date}
|
||||
initialView={view}
|
||||
dayMaxEventRows={3}
|
||||
dayMaxEventRows={5}
|
||||
eventDisplay="block"
|
||||
headerToolbar={false}
|
||||
allDayMaintainDuration
|
||||
eventResizableFromStart
|
||||
displayEventTime={false}
|
||||
select={isMentor ? onSelectRange : undefined}
|
||||
eventClick={onClickEvent}
|
||||
eventClick={handleEventClick}
|
||||
eventDrop={isMentor ? onDropEvent : 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]}
|
||||
locale={ruLocale}
|
||||
timeZone={user?.timezone || 'local'}
|
||||
slotLabelFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
||||
eventTimeFormat={{ hour: '2-digit', minute: '2-digit', hour12: false }}
|
||||
/>
|
||||
|
|
@ -141,6 +621,19 @@ export function CalendarView() {
|
|||
onCreateEvent={handleCreateEvent}
|
||||
onUpdateEvent={handleUpdateEvent}
|
||||
onDeleteEvent={handleDeleteEvent}
|
||||
onCompleteLesson={(lessonId) => { onCloseForm(); handleOpenCompleteLesson(lessonId); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Auto-open after video call redirect (reads sessionStorage) */}
|
||||
{isMentor && <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 Avatar from '@mui/material/Avatar';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import CardActions from '@mui/material/CardActions';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
|
||||
import { paths } from 'src/routes/paths';
|
||||
|
||||
|
|
@ -27,10 +33,95 @@ import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
|
|||
// ----------------------------------------------------------------------
|
||||
|
||||
async function getChildren() {
|
||||
const res = await axios.get('/users/parents/children/');
|
||||
const {data} = res;
|
||||
if (Array.isArray(data)) return data;
|
||||
return data?.results ?? [];
|
||||
const res = await axios.get('/parent/dashboard/');
|
||||
const raw = res.data?.children ?? [];
|
||||
return raw.map((item) => {
|
||||
const c = item.child ?? item;
|
||||
const [first_name = '', ...rest] = (c.name || '').split(' ');
|
||||
return { id: c.id, first_name, last_name: rest.join(' '), email: c.email };
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function AddChildDialog({ open, onClose, onSuccess }) {
|
||||
const [code, setCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleClose = () => {
|
||||
setCode('');
|
||||
setError('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = code.trim().toUpperCase();
|
||||
if (!trimmed) { setError('Введите код'); return; }
|
||||
if (trimmed.length !== 8) { setError('Код должен содержать ровно 8 символов'); return; }
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
await axios.post('/manage/parents/add_child/', { universal_code: trimmed });
|
||||
handleClose();
|
||||
onSuccess();
|
||||
} catch (e) {
|
||||
const msg = e?.response?.data?.error
|
||||
|| e?.response?.data?.detail
|
||||
|| e?.message
|
||||
|| 'Ошибка при добавлении';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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 [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const list = await getChildren();
|
||||
setChildren(list);
|
||||
} catch (e) {
|
||||
|
|
@ -53,15 +146,22 @@ export function ChildrenView() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
<DashboardContent>
|
||||
<CustomBreadcrumbs
|
||||
heading="Мои дети"
|
||||
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 }}
|
||||
/>
|
||||
|
||||
|
|
@ -81,6 +181,14 @@ export function ChildrenView() {
|
|||
<Typography variant="body1" color="text.secondary" sx={{ mt: 2 }}>
|
||||
Нет привязанных детей
|
||||
</Typography>
|
||||
<Button
|
||||
variant="outlined"
|
||||
sx={{ mt: 2 }}
|
||||
startIcon={<Iconify icon="solar:user-plus-bold" width={18} />}
|
||||
onClick={() => setAddOpen(true)}
|
||||
>
|
||||
Добавить ребёнка по коду
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Grid container spacing={2}>
|
||||
|
|
@ -106,10 +214,14 @@ export function ChildrenView() {
|
|||
<Button
|
||||
variant="contained"
|
||||
fullWidth
|
||||
startIcon={<Iconify icon="eva:trending-up-outline" />}
|
||||
onClick={() => router.push(`${paths.dashboard.childrenProgress}?child=${child.id}`)}
|
||||
startIcon={<Iconify icon="solar:chart-square-bold" />}
|
||||
onClick={() => {
|
||||
localStorage.setItem('selected_child', JSON.stringify({ id: child.id, name }));
|
||||
window.dispatchEvent(new Event('child-changed'));
|
||||
router.push(paths.dashboard.root);
|
||||
}}
|
||||
>
|
||||
Прогресс
|
||||
Дашборд
|
||||
</Button>
|
||||
</CardActions>
|
||||
</Card>
|
||||
|
|
@ -118,6 +230,15 @@ export function ChildrenView() {
|
|||
})}
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<AddChildDialog
|
||||
open={addOpen}
|
||||
onClose={() => setAddOpen(false)}
|
||||
onSuccess={() => {
|
||||
load();
|
||||
window.dispatchEvent(new Event('child-changed'));
|
||||
}}
|
||||
/>
|
||||
</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 { paths } from 'src/routes/paths';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
|
||||
import { getHomework, getHomeworkById, getHomeworkStatus } from 'src/utils/homework-api';
|
||||
|
||||
|
|
@ -85,12 +86,30 @@ function HomeworkCard({ hw, userRole, onView, onSubmit, onEdit }) {
|
|||
</Stack>
|
||||
)}
|
||||
|
||||
{userRole === 'mentor' && hw.total_submissions > 0 && (
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
|
||||
{userRole === 'mentor' && (() => {
|
||||
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.checked_submissions > 0 && ` • Проверено: ${hw.checked_submissions}`}
|
||||
</Typography>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
|
||||
{hw.deadline && (
|
||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
||||
|
|
@ -196,8 +215,23 @@ function HomeworkColumn({ title, items, userRole, onView, onSubmit, onEdit, empt
|
|||
|
||||
export function HomeworkView() {
|
||||
const { user } = useAuthContext();
|
||||
const router = useRouter();
|
||||
const userRole = user?.role ?? '';
|
||||
|
||||
const getChildId = () => {
|
||||
if (userRole !== 'parent') return null;
|
||||
try { const s = localStorage.getItem('selected_child'); return s ? (JSON.parse(s)?.id || null) : null; } catch { return null; }
|
||||
};
|
||||
const [childId, setChildId] = useState(getChildId);
|
||||
|
||||
useEffect(() => {
|
||||
if (userRole !== 'parent') return undefined;
|
||||
const handler = () => setChildId(getChildId());
|
||||
window.addEventListener('child-changed', handler);
|
||||
return () => window.removeEventListener('child-changed', handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userRole]);
|
||||
|
||||
const [homework, setHomework] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
|
@ -213,28 +247,24 @@ export function HomeworkView() {
|
|||
const loadHomework = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await getHomework({ page_size: 1000 });
|
||||
const params = { page_size: 1000 };
|
||||
if (childId) params.child_id = childId;
|
||||
const res = await getHomework(params);
|
||||
setHomework(res.results);
|
||||
} catch (e) {
|
||||
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [childId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadHomework();
|
||||
}, [loadHomework]);
|
||||
|
||||
const handleViewDetails = useCallback(async (hw) => {
|
||||
try {
|
||||
const full = await getHomeworkById(hw.id);
|
||||
setSelectedHw(full);
|
||||
} catch {
|
||||
setSelectedHw(hw);
|
||||
}
|
||||
setDetailsOpen(true);
|
||||
}, []);
|
||||
const handleViewDetails = useCallback((hw) => {
|
||||
router.push(paths.dashboard.homeworkDetail(hw.id));
|
||||
}, [router]);
|
||||
|
||||
const handleOpenSubmit = useCallback((hw) => {
|
||||
setSubmitHwId(hw.id);
|
||||
|
|
|
|||
|
|
@ -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?.end_date) q.append('end_date', params.end_date);
|
||||
if (params?.child_id) q.append('child_id', params.child_id);
|
||||
const res = await axios.get(`/lessons/lessons/?${q.toString()}`);
|
||||
const res = await axios.get(`/schedule/lessons/?${q.toString()}`);
|
||||
const {data} = res;
|
||||
if (Array.isArray(data)) return { results: data };
|
||||
return data;
|
||||
|
|
@ -116,11 +116,19 @@ export function MyProgressView() {
|
|||
const [subjects, setSubjects] = useState([]);
|
||||
const [selectedSubject, setSelectedSubject] = useState('');
|
||||
|
||||
const childId = isParent
|
||||
? typeof window !== 'undefined'
|
||||
? localStorage.getItem('selected_child_id') || ''
|
||||
: ''
|
||||
: '';
|
||||
const getChildId = () => {
|
||||
if (!isParent) return '';
|
||||
try { const s = localStorage.getItem('selected_child'); return s ? (JSON.parse(s)?.id || '') : ''; } catch { return ''; }
|
||||
};
|
||||
const [childId, setChildId] = useState(getChildId);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isParent) return undefined;
|
||||
const handler = () => setChildId(getChildId());
|
||||
window.addEventListener('child-changed', handler);
|
||||
return () => window.removeEventListener('child-changed', handler);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isParent]);
|
||||
|
||||
const loadSubjects = useCallback(async () => {
|
||||
const sixMonthsAgo = new Date(Date.now() - 180 * 86400000).toISOString().slice(0, 10);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import CircularProgress from '@mui/material/CircularProgress';
|
|||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
|
||||
import { paths } from 'src/routes/paths';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
import { fDateTime } from 'src/utils/format-time';
|
||||
import { getClientDashboard, getChildDashboard } from 'src/utils/dashboard-api';
|
||||
|
|
@ -66,7 +67,7 @@ function LessonItem({ lesson }) {
|
|||
setJoining(true);
|
||||
const res = await createLiveKitRoom(lesson.id);
|
||||
const token = res?.access_token || res?.token;
|
||||
router.push(`/video-call?token=${token}&lesson_id=${lesson.id}`);
|
||||
router.push(`${paths.dashboard.prejoin}?token=${encodeURIComponent(token)}&lesson_id=${lesson.id}`);
|
||||
} catch (err) {
|
||||
console.error('Join error:', err);
|
||||
setJoining(false);
|
||||
|
|
@ -203,6 +204,16 @@ export function OverviewClientView({ childId, childName }) {
|
|||
return () => controller.abort();
|
||||
}, [fetchData]);
|
||||
|
||||
// Периодическое обновление каждые 60 сек — кнопка «Подключиться» появляется автоматически
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => {
|
||||
const controller = new AbortController();
|
||||
fetchData(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [fetchData]);
|
||||
|
||||
const displayName = childName || user?.first_name || 'Студент';
|
||||
|
||||
const completionPct =
|
||||
|
|
|
|||
|
|
@ -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 { paths } from 'src/routes/paths';
|
||||
import { useRouter } from 'src/routes/hooks';
|
||||
|
||||
import { resolveMediaUrl } from 'src/utils/axios';
|
||||
import {
|
||||
getStudents,
|
||||
getMyMentors,
|
||||
|
|
@ -43,7 +45,6 @@ import {
|
|||
getMentorshipRequestsPending,
|
||||
} from 'src/utils/students-api';
|
||||
|
||||
import { CONFIG } from 'src/config-global';
|
||||
import { DashboardContent } from 'src/layouts/dashboard';
|
||||
|
||||
import { Iconify } from 'src/components/iconify';
|
||||
|
|
@ -53,12 +54,7 @@ import { useAuthContext } from 'src/auth/hooks';
|
|||
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function avatarUrl(href) {
|
||||
if (!href) return null;
|
||||
if (href.startsWith('http://') || href.startsWith('https://')) return href;
|
||||
const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
|
||||
return base + (href.startsWith('/') ? href : `/${href}`);
|
||||
}
|
||||
const avatarUrl = (href) => resolveMediaUrl(href) || null;
|
||||
|
||||
function initials(firstName, lastName) {
|
||||
return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase();
|
||||
|
|
@ -68,6 +64,7 @@ function initials(firstName, lastName) {
|
|||
// MENTOR VIEWS
|
||||
|
||||
function MentorStudentList({ onRefresh }) {
|
||||
const router = useRouter();
|
||||
const [students, setStudents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
|
|
@ -129,11 +126,12 @@ function MentorStudentList({ onRefresh }) {
|
|||
return (
|
||||
<Grid item key={s.id} xs={12} sm={6} md={4} lg={3}>
|
||||
<Card
|
||||
onClick={() => router.push(paths.dashboard.studentDetail(s.id))}
|
||||
sx={{
|
||||
p: 3,
|
||||
textAlign: 'center',
|
||||
height: '100%',
|
||||
cursor: 'default',
|
||||
cursor: 'pointer',
|
||||
transition: 'box-shadow 0.2s, transform 0.2s',
|
||||
'&:hover': {
|
||||
transform: 'translateY(-2px)',
|
||||
|
|
|
|||
|
|
@ -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