feat: migrate Homework, Materials, Students, Notifications to front_minimal

- Homework: kanban view with columns (pending/submitted/returned/reviewed/fill_later/ai_draft), details drawer with submission list for mentor, submit drawer for client, edit draft drawer; full AI-grade support
- Materials: grid view with image preview, upload and delete dialogs
- Students: mentor view with student list + pending requests + invite by email/code; client view with mentors list + incoming invitations + send request by mentor code
- Notifications: full page + NotificationsDrawer in header connected to real API (mark read, delete, mark all)
- New API utils: homework-api.js, materials-api.js, students-api.js, notifications-api.js
- Added routes: /dashboard/homework, /dashboard/materials, /dashboard/students, /dashboard/notifications
- Updated navigation config with new items

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Dev Server 2026-03-09 10:09:30 +03:00
parent 17bed2b321
commit d4ec417ebf
22 changed files with 3259 additions and 89 deletions

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { HomeworkView } from 'src/sections/homework/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Домашние задания | ${CONFIG.site.name}` };
export default function Page() {
return <HomeworkView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { MaterialsView } from 'src/sections/materials/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Материалы | ${CONFIG.site.name}` };
export default function Page() {
return <MaterialsView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { NotificationsView } from 'src/sections/notifications/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Уведомления | ${CONFIG.site.name}` };
export default function Page() {
return <NotificationsView />;
}

View File

@ -0,0 +1,11 @@
import { CONFIG } from 'src/config-global';
import { StudentsView } from 'src/sections/students/view';
// ----------------------------------------------------------------------
export const metadata = { title: `Ученики | ${CONFIG.site.name}` };
export default function Page() {
return <StudentsView />;
}

View File

@ -1,116 +1,122 @@
'use client';
import { m } from 'framer-motion';
import { useState, useCallback } from 'react';
import { useState, useEffect, useCallback } from 'react';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';
import Tab from '@mui/material/Tab';
import List from '@mui/material/List';
import Stack from '@mui/material/Stack';
import Badge from '@mui/material/Badge';
import Drawer from '@mui/material/Drawer';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import SvgIcon from '@mui/material/SvgIcon';
import Tooltip from '@mui/material/Tooltip';
import ListItem from '@mui/material/ListItem';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import ListItemText from '@mui/material/ListItemText';
import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths';
import { useRouter } from 'src/routes/hooks';
import { useBoolean } from 'src/hooks/use-boolean';
import {
markAsRead,
markAllAsRead,
getNotifications,
} from 'src/utils/notifications-api';
import { Label } from 'src/components/label';
import { Iconify } from 'src/components/iconify';
import { varHover } from 'src/components/animate';
import { Scrollbar } from 'src/components/scrollbar';
import { CustomTabs } from 'src/components/custom-tabs';
import { NotificationItem } from './notification-item';
// ----------------------------------------------------------------------
function formatDate(s) {
if (!s) return '';
const d = new Date(s);
return Number.isNaN(d.getTime())
? ''
: d.toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
}
function notifIcon(n) {
const t = n.type || n.notification_type || '';
if (t === 'success') return 'eva:checkmark-circle-2-outline';
if (t === 'warning') return 'eva:alert-triangle-outline';
if (t === 'error') return 'eva:close-circle-outline';
return 'eva:bell-outline';
}
function notifColor(n) {
const t = n.type || n.notification_type || '';
if (t === 'success') return 'success.main';
if (t === 'warning') return 'warning.main';
if (t === 'error') return 'error.main';
return 'info.main';
}
// ----------------------------------------------------------------------
const TABS = [
{ value: 'all', label: 'All', count: 22 },
{ value: 'unread', label: 'Unread', count: 12 },
{ value: 'archived', label: 'Archived', count: 10 },
];
// ----------------------------------------------------------------------
export function NotificationsDrawer({ data = [], sx, ...other }) {
export function NotificationsDrawer({ sx, ...other }) {
const drawer = useBoolean();
const router = useRouter();
const [currentTab, setCurrentTab] = useState('unread');
const [notifications, setNotifications] = useState([]);
const [loading, setLoading] = useState(false);
const [currentTab, setCurrentTab] = useState('all');
const handleChangeTab = useCallback((event, newValue) => {
setCurrentTab(newValue);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await getNotifications({ page_size: 50 });
setNotifications(res.results);
} catch {
// silently fail
} finally {
setLoading(false);
}
}, []);
const [notifications, setNotifications] = useState(data);
// Load when drawer opens
useEffect(() => {
if (drawer.value) load();
}, [drawer.value, load]);
const totalUnRead = notifications.filter((item) => item.isUnRead === true).length;
const unreadCount = notifications.filter((n) => !n.is_read).length;
const handleMarkAllAsRead = () => {
setNotifications(notifications.map((notification) => ({ ...notification, isUnRead: false })));
const filtered =
currentTab === 'unread'
? notifications.filter((n) => !n.is_read)
: notifications;
const handleMarkAllAsRead = async () => {
try {
await markAllAsRead();
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
} catch {
// ignore
}
};
const renderHead = (
<Stack direction="row" alignItems="center" sx={{ py: 2, pl: 2.5, pr: 1, minHeight: 68 }}>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Notifications
</Typography>
{!!totalUnRead && (
<Tooltip title="Mark all as read">
<IconButton color="primary" onClick={handleMarkAllAsRead}>
<Iconify icon="eva:done-all-fill" />
</IconButton>
</Tooltip>
)}
<IconButton onClick={drawer.onFalse} sx={{ display: { xs: 'inline-flex', sm: 'none' } }}>
<Iconify icon="mingcute:close-line" />
</IconButton>
<IconButton>
<Iconify icon="solar:settings-bold-duotone" />
</IconButton>
</Stack>
);
const renderTabs = (
<CustomTabs variant="fullWidth" value={currentTab} onChange={handleChangeTab}>
{TABS.map((tab) => (
<Tab
key={tab.value}
iconPosition="end"
value={tab.value}
label={tab.label}
icon={
<Label
variant={((tab.value === 'all' || tab.value === currentTab) && 'filled') || 'soft'}
color={
(tab.value === 'unread' && 'info') ||
(tab.value === 'archived' && 'success') ||
'default'
const handleMarkOne = async (id) => {
try {
await markAsRead(id);
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, is_read: true } : n));
} catch {
// ignore
}
>
{tab.count}
</Label>
}
/>
))}
</CustomTabs>
);
};
const renderList = (
<Scrollbar>
<Box component="ul">
{notifications?.map((notification) => (
<Box component="li" key={notification.id} sx={{ display: 'flex' }}>
<NotificationItem notification={notification} />
</Box>
))}
</Box>
</Scrollbar>
);
const TABS = [
{ value: 'unread', label: 'Непрочитанные', count: unreadCount },
{ value: 'all', label: 'Все', count: notifications.length },
];
return (
<>
@ -123,9 +129,8 @@ export function NotificationsDrawer({ data = [], sx, ...other }) {
sx={sx}
{...other}
>
<Badge badgeContent={totalUnRead} color="error">
<Badge badgeContent={unreadCount} color="error">
<SvgIcon>
{/* https://icon-sets.iconify.design/solar/bell-bing-bold-duotone/ */}
<path
fill="currentColor"
d="M18.75 9v.704c0 .845.24 1.671.692 2.374l1.108 1.723c1.011 1.574.239 3.713-1.52 4.21a25.794 25.794 0 0 1-14.06 0c-1.759-.497-2.531-2.636-1.52-4.21l1.108-1.723a4.393 4.393 0 0 0 .693-2.374V9c0-3.866 3.022-7 6.749-7s6.75 3.134 6.75 7"
@ -146,15 +151,116 @@ export function NotificationsDrawer({ data = [], sx, ...other }) {
slotProps={{ backdrop: { invisible: true } }}
PaperProps={{ sx: { width: 1, maxWidth: 420 } }}
>
{renderHead}
{/* Head */}
<Stack
direction="row"
alignItems="center"
sx={{ py: 2, pl: 2.5, pr: 1, minHeight: 68, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Typography variant="h6" sx={{ flexGrow: 1 }}>
Уведомления
</Typography>
{unreadCount > 0 && (
<Tooltip title="Отметить все прочитанными">
<IconButton color="primary" onClick={handleMarkAllAsRead}>
<Iconify icon="eva:done-all-fill" />
</IconButton>
</Tooltip>
)}
<IconButton onClick={drawer.onFalse} sx={{ display: { xs: 'inline-flex', sm: 'none' } }}>
<Iconify icon="mingcute:close-line" />
</IconButton>
</Stack>
{renderTabs}
{/* Tabs */}
<CustomTabs variant="fullWidth" value={currentTab} onChange={(_, v) => setCurrentTab(v)}>
{TABS.map((tab) => (
<Tab
key={tab.value}
iconPosition="end"
value={tab.value}
label={tab.label}
icon={
<Label
variant={tab.value === currentTab ? 'filled' : 'soft'}
color={tab.value === 'unread' ? 'info' : 'default'}
>
{tab.count}
</Label>
}
/>
))}
</CustomTabs>
{renderList}
{/* List */}
<Scrollbar sx={{ flex: 1 }}>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : filtered.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 6 }}>
<Iconify icon="eva:bell-off-outline" width={48} color="text.disabled" />
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
{currentTab === 'unread' ? 'Нет непрочитанных' : 'Нет уведомлений'}
</Typography>
</Box>
) : (
<List disablePadding>
{filtered.map((n, idx) => (
<Box key={n.id}>
<ListItem
sx={{
alignItems: 'flex-start',
bgcolor: n.is_read ? 'transparent' : 'action.selected',
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' },
py: 1.5,
px: 2.5,
}}
onClick={() => !n.is_read && handleMarkOne(n.id)}
>
<Box sx={{ mr: 1.5, mt: 0.5, flexShrink: 0 }}>
<Iconify icon={notifIcon(n)} width={20} color={notifColor(n)} />
</Box>
<ListItemText
primary={
<Stack direction="row" alignItems="center" spacing={0.75}>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
{n.title || 'Уведомление'}
</Typography>
{!n.is_read && (
<Box sx={{ width: 7, height: 7, borderRadius: '50%', bgcolor: 'primary.main', flexShrink: 0 }} />
)}
</Stack>
}
secondary={
<>
<Typography variant="body2" color="text.secondary" component="span" sx={{ display: 'block' }}>
{n.message}
</Typography>
<Typography variant="caption" color="text.disabled" component="span">
{formatDate(n.created_at)}
</Typography>
</>
}
/>
</ListItem>
{idx < filtered.length - 1 && <Divider />}
</Box>
))}
</List>
)}
</Scrollbar>
<Box sx={{ p: 1 }}>
<Button fullWidth size="large">
View all
{/* Footer */}
<Box sx={{ p: 1, borderTop: '1px solid', borderColor: 'divider' }}>
<Button
fullWidth
size="large"
onClick={() => { drawer.onFalse(); router.push(paths.dashboard.notifications); }}
>
Смотреть все
</Button>
</Box>
</Drawer>

View File

@ -12,6 +12,8 @@ const ICONS = {
course: icon('ic-course'),
calendar: icon('ic-calendar'),
dashboard: icon('ic-dashboard'),
kanban: icon('ic-kanban'),
folder: icon('ic-folder'),
};
// ----------------------------------------------------------------------
@ -32,9 +34,12 @@ export const navData = [
{
subheader: 'Инструменты',
items: [
{ title: 'Ученики', path: paths.dashboard.user.list, icon: ICONS.user },
{ title: 'Schedule', path: paths.dashboard.calendar, icon: ICONS.calendar },
{ title: 'Ученики', 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.materials, icon: ICONS.folder },
{ title: 'Чат', path: paths.dashboard.chat, icon: ICONS.chat },
{ title: 'Уведомления', path: paths.dashboard.notifications, icon: icon('ic-label') },
],
},
];

View File

@ -57,6 +57,9 @@ export const paths = {
jwt: {
signIn: `${ROOTS.AUTH}/jwt/sign-in`,
signUp: `${ROOTS.AUTH}/jwt/sign-up`,
forgotPassword: `${ROOTS.AUTH}/jwt/forgot-password`,
resetPassword: `${ROOTS.AUTH}/jwt/reset-password`,
verifyEmail: `${ROOTS.AUTH}/jwt/verify-email`,
},
firebase: {
signIn: `${ROOTS.AUTH}/firebase/sign-in`,
@ -99,6 +102,10 @@ export const paths = {
blank: `${ROOTS.DASHBOARD}/blank`,
kanban: `${ROOTS.DASHBOARD}/kanban`,
calendar: `${ROOTS.DASHBOARD}/schedule`,
homework: `${ROOTS.DASHBOARD}/homework`,
materials: `${ROOTS.DASHBOARD}/materials`,
students: `${ROOTS.DASHBOARD}/students`,
notifications: `${ROOTS.DASHBOARD}/notifications`,
fileManager: `${ROOTS.DASHBOARD}/file-manager`,
permission: `${ROOTS.DASHBOARD}/permission`,
general: {

View File

@ -0,0 +1,605 @@
'use client';
import { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import Drawer from '@mui/material/Drawer';
import Rating from '@mui/material/Rating';
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 {
getMySubmission,
gradeSubmission,
deleteSubmission,
checkSubmissionWithAi,
getHomeworkSubmissions,
returnSubmissionForRevision,
} from 'src/utils/homework-api';
import { CONFIG } from 'src/config-global';
import { Iconify } from 'src/components/iconify';
// ----------------------------------------------------------------------
function fileUrl(href) {
if (!href) return '';
if (href.startsWith('http://') || href.startsWith('https://')) return href;
const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
return base + (href.startsWith('/') ? href : `/${href}`);
}
function formatDateTime(s) {
if (!s) return '—';
const d = new Date(s);
return Number.isNaN(d.getTime())
? '—'
: d.toLocaleString('ru-RU', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// ----------------------------------------------------------------------
// File list component
function FileList({ files, singleUrl, label }) {
const items = [];
if (singleUrl) items.push({ label: label || 'Файл', url: fileUrl(singleUrl) });
(files ?? []).forEach((f) => {
if (f.file) items.push({ label: f.filename || 'Файл', url: fileUrl(f.file) });
});
if (!items.length) return null;
return (
<Stack spacing={1}>
{items.map(({ label: l, url }, i) => (
<Button
key={i}
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
variant="outlined"
size="small"
startIcon={<Iconify icon="eva:download-outline" />}
sx={{ justifyContent: 'flex-start' }}
>
{l}
</Button>
))}
</Stack>
);
}
// ----------------------------------------------------------------------
// Client view own submission
function ClientSubmissionSection({ homework, childId, onOpenSubmit, onReload }) {
const [submission, setSubmission] = useState(undefined); // undefined = loading
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
getMySubmission(homework.id, childId ? { child_id: childId } : undefined)
.then(setSubmission)
.catch(() => setSubmission(null));
}, [homework.id, childId]);
const handleDelete = async () => {
try {
setDeleting(true);
await deleteSubmission(submission.id);
setSubmission(null);
onReload();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка');
} finally {
setDeleting(false);
}
};
if (submission === undefined) return <CircularProgress size={24} />;
if (!submission) {
return (
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Вы ещё не сдали задание.
</Typography>
{homework.status === 'published' && !childId && (
<Button variant="contained" onClick={onOpenSubmit}>
Сдать ДЗ
</Button>
)}
</Box>
);
}
return (
<Stack spacing={2}>
<Stack direction="row" alignItems="center" spacing={1}>
<Chip
label={submission.status === 'returned' ? 'На доработке' : submission.status === 'checked' ? 'Проверено' : 'На проверке'}
color={submission.status === 'returned' ? 'warning' : submission.status === 'checked' ? 'success' : 'info'}
size="small"
/>
<Typography variant="caption" color="text.secondary">
{formatDateTime(submission.submitted_at)}
</Typography>
</Stack>
{submission.content && (
<Box sx={{ p: 2, bgcolor: 'background.neutral', borderRadius: 1 }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{submission.content}
</Typography>
</Box>
)}
<FileList
singleUrl={submission.attachment}
files={(submission.files ?? []).filter((f) => f.file_type === 'submission')}
label="Решение"
/>
{submission.score != null && (
<Stack spacing={0.5}>
<Typography variant="subtitle2">Оценка</Typography>
<Rating value={submission.score} max={5} readOnly />
<Typography variant="body2" color="text.secondary">
{submission.score} / {homework.max_score || 5}
</Typography>
</Stack>
)}
{submission.feedback && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Комментарий ментора
</Typography>
{submission.feedback_html ? (
<Box
sx={{ p: 2, bgcolor: 'background.neutral', borderRadius: 1 }}
dangerouslySetInnerHTML={{ __html: submission.feedback_html }}
/>
) : (
<Box sx={{ p: 2, bgcolor: 'background.neutral', borderRadius: 1 }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{submission.feedback}
</Typography>
</Box>
)}
</Box>
)}
{error && <Alert severity="error">{error}</Alert>}
{submission.status !== 'checked' && !childId && (
<Stack direction="row" spacing={1}>
<Button variant="outlined" color="error" size="small" onClick={handleDelete} disabled={deleting}>
{deleting ? 'Удаление...' : 'Удалить и переотправить'}
</Button>
</Stack>
)}
</Stack>
);
}
// ----------------------------------------------------------------------
// Mentor submission item
function MentorSubmissionItem({ submission, homework, onReload }) {
const [score, setScore] = useState(submission.score != null ? submission.score : '');
const [feedback, setFeedback] = useState('');
const [returnFeedback, setReturnFeedback] = useState('');
const [showReturnForm, setShowReturnForm] = useState(false);
const [grading, setGrading] = useState(false);
const [returning, setReturning] = useState(false);
const [aiChecking, setAiChecking] = useState(false);
const [error, setError] = useState(null);
const {student} = submission;
const handleGrade = async () => {
if (!score && score !== 0) {
setError('Укажите оценку');
return;
}
try {
setGrading(true);
setError(null);
await gradeSubmission(submission.id, { score: Number(score), feedback: feedback.trim() });
onReload();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка');
} finally {
setGrading(false);
}
};
const handleReturn = async () => {
if (!returnFeedback.trim()) {
setError('Укажите причину возврата');
return;
}
try {
setReturning(true);
setError(null);
await returnSubmissionForRevision(submission.id, returnFeedback.trim());
onReload();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка');
} finally {
setReturning(false);
}
};
const handleAiCheck = async () => {
try {
setAiChecking(true);
setError(null);
const result = await checkSubmissionWithAi(submission.id);
if (result.ai_score != null) setScore(result.ai_score);
if (result.ai_feedback) setFeedback(result.ai_feedback);
onReload();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка AI-проверки');
} finally {
setAiChecking(false);
}
};
const isChecked = submission.status === 'checked';
const isReturned = submission.status === 'returned';
return (
<Box
sx={{
p: 2,
borderRadius: 2,
border: '1px solid',
borderColor: 'divider',
bgcolor: 'background.neutral',
}}
>
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ mb: 1.5 }}>
<Typography variant="subtitle2">
{student?.first_name} {student?.last_name}
</Typography>
<Chip
label={isChecked ? 'Проверено' : isReturned ? 'На доработке' : 'На проверке'}
color={isChecked ? 'success' : isReturned ? 'warning' : 'info'}
size="small"
/>
</Stack>
{submission.content && (
<Box sx={{ mb: 1.5, p: 1.5, bgcolor: 'background.paper', borderRadius: 1 }}>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{submission.content}
</Typography>
</Box>
)}
<FileList
singleUrl={submission.attachment}
files={(submission.files ?? []).filter((f) => f.file_type === 'submission')}
label="Решение"
/>
{/* AI draft info */}
{submission.ai_score != null && !isChecked && (
<Box sx={{ mt: 1.5, p: 1.5, bgcolor: 'info.lighter', borderRadius: 1 }}>
<Typography variant="caption" color="info.main" fontWeight={600}>
Черновик от ИИ: оценка {submission.ai_score}/5
</Typography>
{submission.ai_feedback && (
<Typography variant="body2" sx={{ mt: 0.5, whiteSpace: 'pre-wrap', fontSize: 12 }}>
{submission.ai_feedback}
</Typography>
)}
</Box>
)}
{error && (
<Alert severity="error" sx={{ mt: 1 }}>
{error}
</Alert>
)}
{/* Grade form */}
{!isChecked && !showReturnForm && (
<Stack spacing={1.5} sx={{ mt: 2 }}>
<Stack direction="row" alignItems="center" spacing={2}>
<Typography variant="body2" color="text.secondary">
Оценка:
</Typography>
<Rating
value={Number(score) || 0}
max={homework.max_score || 5}
onChange={(_, v) => setScore(v ?? '')}
/>
</Stack>
<TextField
label="Комментарий"
multiline
rows={2}
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
size="small"
fullWidth
/>
<Stack direction="row" spacing={1} flexWrap="wrap">
<Button
variant="contained"
size="small"
onClick={handleGrade}
disabled={grading || aiChecking}
startIcon={grading ? <Iconify icon="svg-spinners:8-dots-rotate" /> : null}
>
{grading ? 'Сохранение...' : 'Выставить оценку'}
</Button>
<Button
variant="outlined"
size="small"
color="warning"
onClick={() => setShowReturnForm(true)}
disabled={grading || aiChecking}
>
Вернуть на доработку
</Button>
<Button
variant="outlined"
size="small"
color="info"
onClick={handleAiCheck}
disabled={grading || aiChecking}
startIcon={
aiChecking ? (
<Iconify icon="svg-spinners:8-dots-rotate" />
) : (
<Iconify icon="solar:magic-stick-3-bold" />
)
}
>
{aiChecking ? 'Проверка...' : 'ИИ-проверка'}
</Button>
</Stack>
</Stack>
)}
{/* Return form */}
{showReturnForm && (
<Stack spacing={1.5} sx={{ mt: 2 }}>
<TextField
label="Причина возврата"
multiline
rows={2}
value={returnFeedback}
onChange={(e) => setReturnFeedback(e.target.value)}
size="small"
fullWidth
/>
<Stack direction="row" spacing={1}>
<Button
variant="contained"
color="warning"
size="small"
onClick={handleReturn}
disabled={returning}
>
{returning ? 'Отправка...' : 'Вернуть'}
</Button>
<Button
variant="outlined"
size="small"
onClick={() => setShowReturnForm(false)}
disabled={returning}
>
Отмена
</Button>
</Stack>
</Stack>
)}
</Box>
);
}
// ----------------------------------------------------------------------
// Main drawer
export function HomeworkDetailsDrawer({ open, homework, userRole, childId, onClose, onSuccess, onOpenSubmit, onOpenEdit }) {
const [submissions, setSubmissions] = useState([]);
const [subsLoading, setSubsLoading] = useState(false);
const [tab, setTab] = useState(0);
const isMentor = userRole === 'mentor';
const loadSubmissions = () => {
if (!homework || !isMentor) return;
setSubsLoading(true);
getHomeworkSubmissions(homework.id)
.then(setSubmissions)
.catch(() => setSubmissions([]))
.finally(() => setSubsLoading(false));
};
useEffect(() => {
if (open && homework && isMentor) {
loadSubmissions();
setTab(0);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, homework?.id, isMentor]);
if (!homework) return null;
const assignmentFiles = (homework.files ?? []).filter((f) => f.file_type === 'assignment');
return (
<Drawer
anchor="right"
open={open}
onClose={onClose}
PaperProps={{ sx: { width: { xs: '100%', sm: 560 } } }}
>
{/* Header */}
<Stack
direction="row"
alignItems="flex-start"
justifyContent="space-between"
sx={{ px: 3, py: 2.5, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Box sx={{ flex: 1, pr: 2 }}>
<Typography variant="h6">{homework.title}</Typography>
{homework.deadline && (
<Typography variant="caption" color={homework.is_overdue ? 'error' : 'text.secondary'}>
Дедлайн: {formatDateTime(homework.deadline)}
{homework.is_overdue && ' • Просрочено'}
</Typography>
)}
</Box>
<IconButton onClick={onClose} sx={{ flexShrink: 0 }}>
<Iconify icon="mingcute:close-line" />
</IconButton>
</Stack>
{/* Tabs for mentor */}
{isMentor && (
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ px: 3, borderBottom: '1px solid', borderColor: 'divider' }}>
<Tab label="Задание" />
<Tab label={`Решения (${homework.total_submissions || submissions.length})`} />
</Tabs>
)}
<Box sx={{ p: 3, overflowY: 'auto', flex: 1 }}>
{/* Assignment tab / single view */}
{(!isMentor || tab === 0) && (
<Stack spacing={3}>
{homework.description && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Описание
</Typography>
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
{homework.description}
</Typography>
</Box>
)}
{(homework.attachment || assignmentFiles.length > 0 || homework.attachment_url) && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1 }}>
Файлы задания
</Typography>
<FileList
singleUrl={homework.attachment}
files={assignmentFiles}
label="Файл задания"
/>
{homework.attachment_url && (
<Button
component="a"
href={homework.attachment_url}
target="_blank"
rel="noopener noreferrer"
variant="outlined"
size="small"
startIcon={<Iconify icon="eva:external-link-outline" />}
sx={{ mt: 1, justifyContent: 'flex-start' }}
>
Ссылка на материал
</Button>
)}
</Box>
)}
{isMentor && homework.fill_later && (
<Alert
severity="warning"
action={
<Button size="small" onClick={() => { onClose(); onOpenEdit(homework); }}>
Заполнить
</Button>
}
>
Задание ожидает заполнения
</Alert>
)}
{isMentor && (
<Stack spacing={1}>
<Typography variant="caption" color="text.secondary">
Статус: {homework.status === 'published' ? 'Опубликовано' : homework.status === 'draft' ? 'Черновик' : 'Архив'}
</Typography>
{homework.average_score > 0 && (
<Typography variant="caption" color="text.secondary">
Средняя оценка: {homework.average_score.toFixed(1)} / 5
</Typography>
)}
</Stack>
)}
{/* Client submission section */}
{(userRole === 'client' || userRole === 'parent') && homework.status === 'published' && (
<>
<Divider />
<Box>
<Typography variant="subtitle1" sx={{ mb: 2 }}>
Ваше решение
</Typography>
<ClientSubmissionSection
homework={homework}
childId={userRole === 'parent' ? childId : null}
onOpenSubmit={onOpenSubmit}
onReload={onSuccess}
/>
</Box>
</>
)}
</Stack>
)}
{/* Submissions tab for mentor */}
{isMentor && tab === 1 && (
<Stack spacing={2}>
{subsLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : submissions.length === 0 ? (
<Typography variant="body2" color="text.secondary">
Нет решений
</Typography>
) : (
submissions.map((sub) => (
<MentorSubmissionItem
key={sub.id}
submission={sub}
homework={homework}
onReload={() => {
loadSubmissions();
onSuccess();
}}
/>
))
)}
</Stack>
)}
</Box>
</Drawer>
);
}

View File

@ -0,0 +1,254 @@
'use client';
import { useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import Drawer from '@mui/material/Drawer';
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 { MobileDateTimePicker } from '@mui/x-date-pickers/MobileDateTimePicker';
import {
updateHomework,
publishHomework,
uploadHomeworkFile,
deleteHomeworkFile,
} from 'src/utils/homework-api';
import { Iconify } from 'src/components/iconify';
// ----------------------------------------------------------------------
export function HomeworkEditDrawer({ open, homework, onClose, onSuccess }) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [deadline, setDeadline] = useState(null);
const [existingFiles, setExistingFiles] = useState([]);
const [uploadingCount, setUploadingCount] = useState(0);
const [saving, setSaving] = useState(false);
const [publishing, setPublishing] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
if (!open || !homework) return;
setTitle(homework.title || '');
setDescription(homework.description || '');
setDeadline(homework.deadline ? new Date(homework.deadline) : null);
setExistingFiles(homework.files?.filter((f) => f.file_type === 'assignment') || []);
setError(null);
}, [open, homework]);
const handleFileChange = (e) => {
const newFiles = Array.from(e.target.files || []);
if (!newFiles.length || !homework) return;
e.target.value = '';
const validFiles = newFiles.filter((file) => {
if (file.size > 10 * 1024 * 1024) {
setError(`Файл "${file.name}" больше 10 МБ`);
return false;
}
return true;
});
if (!validFiles.length) return;
setUploadingCount((c) => c + validFiles.length);
Promise.all(
validFiles.map((file) =>
uploadHomeworkFile(homework.id, file)
.then((uploaded) => {
setExistingFiles((prev) => [...prev, uploaded]);
})
.catch((err) => {
setError(err?.response?.data?.detail || err?.message || 'Ошибка загрузки файла');
})
.finally(() => {
setUploadingCount((c) => c - 1);
})
)
);
};
const handleRemoveFile = async (fileId) => {
try {
await deleteHomeworkFile(fileId);
setExistingFiles((prev) => prev.filter((f) => f.id !== fileId));
} catch (err) {
setError(err?.response?.data?.detail || err?.message || 'Ошибка удаления файла');
}
};
const handleSave = async () => {
if (!homework) return;
try {
setError(null);
setSaving(true);
await updateHomework(homework.id, {
title: title.trim() || homework.title,
description: description.trim(),
deadline: deadline ? deadline.toISOString() : null,
});
onSuccess();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка сохранения');
} finally {
setSaving(false);
}
};
const handlePublish = async () => {
if (!homework) return;
if (!title.trim()) {
setError('Укажите название задания');
return;
}
if (!description.trim()) {
setError('Укажите текст задания');
return;
}
try {
setError(null);
setPublishing(true);
await updateHomework(homework.id, {
title: title.trim(),
description: description.trim(),
deadline: deadline ? deadline.toISOString() : null,
});
await publishHomework(homework.id);
onSuccess();
onClose();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка публикации');
} finally {
setPublishing(false);
}
};
const isLoading = saving || publishing || uploadingCount > 0;
return (
<Drawer
anchor="right"
open={open}
onClose={isLoading ? undefined : onClose}
PaperProps={{ sx: { width: { xs: '100%', sm: 560 } } }}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ px: 3, py: 2.5, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Typography variant="h6">Заполнить домашнее задание</Typography>
<IconButton onClick={onClose} disabled={isLoading}>
<Iconify icon="mingcute:close-line" />
</IconButton>
</Stack>
<Box sx={{ p: 3, overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}>
<TextField
label="Название задания *"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={isLoading}
fullWidth
/>
<TextField
label="Текст задания *"
multiline
rows={4}
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={isLoading}
placeholder="Опишите задание, шаги, ссылки..."
fullWidth
/>
<MobileDateTimePicker
label="Дедлайн (опционально)"
value={deadline}
onChange={setDeadline}
disabled={isLoading}
slotProps={{ textField: { fullWidth: true } }}
/>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: 'text.secondary' }}>
Файлы к заданию
</Typography>
<input
type="file"
multiple
id="edit-hw-file"
onChange={handleFileChange}
disabled={isLoading}
accept=".pdf,.doc,.docx,.txt,.jpg,.jpeg,.png,.zip,.rar"
style={{ display: 'none' }}
/>
<Button
component="label"
htmlFor="edit-hw-file"
variant="outlined"
startIcon={
uploadingCount > 0 ? (
<CircularProgress size={16} />
) : (
<Iconify icon="eva:upload-outline" />
)
}
fullWidth
disabled={isLoading}
sx={{ py: 1.5, border: '2px dashed', '&:hover': { border: '2px dashed' } }}
>
{uploadingCount > 0 ? `Загрузка ${uploadingCount}...` : 'Загрузить файлы'}
</Button>
{existingFiles.length > 0 && (
<Stack direction="row" flexWrap="wrap" gap={1} sx={{ mt: 1.5 }}>
{existingFiles.map((file) => (
<Chip
key={file.id}
label={file.filename || 'Файл'}
size="small"
onDelete={() => handleRemoveFile(file.id)}
icon={<Iconify icon="eva:file-outline" width={14} />}
/>
))}
</Stack>
)}
</Box>
{error && <Alert severity="error">{error}</Alert>}
<Stack direction="row" spacing={1.5} flexWrap="wrap">
<Button
variant="contained"
onClick={handlePublish}
disabled={isLoading}
startIcon={publishing ? <Iconify icon="svg-spinners:8-dots-rotate" /> : null}
>
{publishing ? 'Публикация...' : 'Опубликовать ДЗ'}
</Button>
<Button
variant="outlined"
onClick={handleSave}
disabled={isLoading}
startIcon={saving ? <Iconify icon="svg-spinners:8-dots-rotate" /> : null}
>
{saving ? 'Сохранение...' : 'Сохранить черновик'}
</Button>
</Stack>
</Box>
</Drawer>
);
}

View File

@ -0,0 +1,265 @@
'use client';
import { useMemo, useState, useEffect } from 'react';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import Drawer from '@mui/material/Drawer';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import LinearProgress from '@mui/material/LinearProgress';
import { submitHomework, getHomeworkById, validateHomeworkFiles } from 'src/utils/homework-api';
import { CONFIG } from 'src/config-global';
import { Iconify } from 'src/components/iconify';
// ----------------------------------------------------------------------
function fileUrl(href) {
if (!href) return '';
if (href.startsWith('http://') || href.startsWith('https://')) return href;
const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
return base + (href.startsWith('/') ? href : `/${href}`);
}
// ----------------------------------------------------------------------
export function HomeworkSubmitDrawer({ open, homeworkId, onClose, onSuccess }) {
const [homework, setHomework] = useState(null);
const [content, setContent] = useState('');
const [files, setFiles] = useState([]);
const [loading, setLoading] = useState(false);
const [uploadProgress, setUploadProgress] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
if (!open) {
setContent('');
setFiles([]);
setUploadProgress(null);
setError(null);
setHomework(null);
} else if (homeworkId) {
getHomeworkById(homeworkId)
.then(setHomework)
.catch(() => setHomework(null));
}
}, [open, homeworkId]);
const assignmentFiles = useMemo(() => {
if (!homework) return [];
const list = [];
if (homework.attachment) list.push({ label: 'Файл задания', url: fileUrl(homework.attachment) });
(homework.files ?? [])
.filter((f) => f.file_type === 'assignment')
.forEach((f) => {
if (f.file) list.push({ label: f.filename || 'Файл', url: fileUrl(f.file) });
});
if (homework.attachment_url?.trim())
list.push({ label: 'Ссылка на материал', url: homework.attachment_url.trim() });
return list;
}, [homework]);
const handleFileChange = (e) => {
const list = Array.from(e.target.files || []);
const combined = [...files, ...list];
const { valid, error: validationError } = validateHomeworkFiles(combined);
if (!valid) {
setError(validationError ?? 'Ошибка файлов');
return;
}
setFiles(combined);
setError(null);
e.target.value = '';
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!content.trim() && files.length === 0) {
setError('Укажите текст или прикрепите файлы');
return;
}
const { valid, error: validationError } = validateHomeworkFiles(files);
if (!valid) {
setError(validationError ?? 'Ошибка файлов');
return;
}
try {
setLoading(true);
setError(null);
setUploadProgress(0);
await submitHomework(homeworkId, { content: content.trim(), files }, (p) =>
setUploadProgress(p)
);
await onSuccess();
onClose();
} catch (err) {
setError(err?.response?.data?.detail || err?.message || 'Ошибка отправки');
setUploadProgress(null);
} finally {
setLoading(false);
}
};
return (
<Drawer
anchor="right"
open={open}
onClose={loading ? undefined : onClose}
PaperProps={{ sx: { width: { xs: '100%', sm: 460 } } }}
>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{ px: 3, py: 2.5, borderBottom: '1px solid', borderColor: 'divider' }}
>
<Typography variant="h6">Отправить решение</Typography>
<IconButton onClick={onClose} disabled={loading}>
<Iconify icon="mingcute:close-line" />
</IconButton>
</Stack>
<Box
component="form"
onSubmit={handleSubmit}
sx={{ p: 3, overflowY: 'auto', flex: 1, display: 'flex', flexDirection: 'column', gap: 3 }}
>
{assignmentFiles.length > 0 && (
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: 'text.secondary' }}>
Файлы задания (от ментора)
</Typography>
<Stack spacing={1}>
{assignmentFiles.map(({ label, url }, i) => (
<Button
key={i}
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
variant="outlined"
startIcon={<Iconify icon="eva:download-outline" />}
sx={{ justifyContent: 'flex-start', textAlign: 'left' }}
>
{label}
</Button>
))}
</Stack>
</Box>
)}
<TextField
label="Текст решения"
multiline
rows={6}
value={content}
onChange={(e) => setContent(e.target.value)}
disabled={loading}
placeholder="Введите текст решения..."
fullWidth
/>
<Box>
<Typography variant="subtitle2" sx={{ mb: 1.5, color: 'text.secondary' }}>
Файлы (до 50 МБ, не более 10 шт.)
</Typography>
<input
id="hw-submit-file"
type="file"
multiple
onChange={handleFileChange}
disabled={loading}
accept="image/*,video/*,audio/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip"
style={{ display: 'none' }}
/>
<Button
component="label"
htmlFor="hw-submit-file"
variant="outlined"
startIcon={<Iconify icon="eva:upload-outline" />}
fullWidth
disabled={loading}
sx={{
py: 2,
border: '2px dashed',
'&:hover': { border: '2px dashed' },
}}
>
Прикрепить файлы
</Button>
{files.length > 0 && (
<Stack spacing={0.5} sx={{ mt: 1.5 }}>
{files.map((f, i) => (
<Stack
key={`${f.name}-${i}`}
direction="row"
alignItems="center"
justifyContent="space-between"
sx={{
px: 1.5,
py: 1,
borderRadius: 1,
bgcolor: 'background.neutral',
border: '1px solid',
borderColor: 'divider',
}}
>
<Typography variant="body2" noWrap sx={{ flex: 1 }}>
{f.name}
</Typography>
<IconButton
size="small"
onClick={() => setFiles((p) => p.filter((_, j) => j !== i))}
disabled={loading}
color="error"
>
<Iconify icon="mingcute:close-line" width={16} />
</IconButton>
</Stack>
))}
</Stack>
)}
{uploadProgress != null && (
<Box sx={{ mt: 2 }}>
<Stack direction="row" justifyContent="space-between" sx={{ mb: 0.5 }}>
<Typography variant="caption" color="text.secondary">
Загрузка на сервер
</Typography>
<Typography variant="caption" fontWeight={600} color="primary">
{uploadProgress}%
</Typography>
</Stack>
<LinearProgress variant="determinate" value={uploadProgress} />
</Box>
)}
</Box>
{error && <Alert severity="error">{error}</Alert>}
<Stack direction="row" spacing={1.5}>
<Button variant="outlined" onClick={onClose} disabled={loading} fullWidth>
Отмена
</Button>
<Button
type="submit"
variant="contained"
disabled={loading || (!content.trim() && files.length === 0)}
fullWidth
startIcon={loading ? <Iconify icon="svg-spinners:8-dots-rotate" /> : null}
>
{loading ? (uploadProgress != null ? `${uploadProgress}%` : 'Отправка...') : 'Отправить'}
</Button>
</Stack>
</Box>
</Drawer>
);
}

View File

@ -0,0 +1,406 @@
'use client';
import { useMemo, useState, useEffect, useCallback } 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 Button from '@mui/material/Button';
import Typography from '@mui/material/Typography';
import CardContent from '@mui/material/CardContent';
import CardActionArea from '@mui/material/CardActionArea';
import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths';
import { getHomework, getHomeworkById, getHomeworkStatus } from 'src/utils/homework-api';
import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { useAuthContext } from 'src/auth/hooks';
import { HomeworkEditDrawer } from '../homework-edit-drawer';
import { HomeworkSubmitDrawer } from '../homework-submit-drawer';
import { HomeworkDetailsDrawer } from '../homework-details-drawer';
// ----------------------------------------------------------------------
function formatDate(s) {
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime())
? null
: d.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long', year: 'numeric' });
}
function formatTime(s) {
if (!s) return null;
const d = new Date(s);
return Number.isNaN(d.getTime())
? null
: d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
// ----------------------------------------------------------------------
function HomeworkCard({ hw, userRole, onView, onSubmit, onEdit }) {
const status = getHomeworkStatus(hw);
const isFillLater = hw.fill_later === true;
const statusConfig = {
pending: { label: hw.is_overdue ? 'Просрочено' : 'Ожидает сдачи', color: hw.is_overdue ? 'error' : 'default' },
submitted: { label: 'На проверке', color: 'info' },
returned: { label: 'На доработке', color: 'warning' },
reviewed: { label: 'Проверено', color: 'success' },
};
const statusInfo = statusConfig[status] || statusConfig.pending;
return (
<Card variant="outlined">
<CardActionArea onClick={onView}>
<CardContent>
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1} sx={{ mb: 1 }}>
<Typography variant="subtitle2" sx={{ flex: 1 }}>
{hw.title}
</Typography>
<Chip
label={isFillLater ? 'Заполнить позже' : statusInfo.label}
color={isFillLater ? 'warning' : statusInfo.color}
size="small"
sx={{ flexShrink: 0 }}
/>
</Stack>
{userRole === 'client' && (
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ mb: 0.5 }}>
<Iconify icon="eva:person-outline" width={14} color="text.secondary" />
<Typography variant="caption" color="text.secondary">
{hw.mentor?.first_name} {hw.mentor?.last_name}
</Typography>
</Stack>
)}
{userRole === 'mentor' && hw.total_submissions > 0 && (
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 0.5 }}>
Решений: {hw.total_submissions}
{hw.checked_submissions > 0 && ` • Проверено: ${hw.checked_submissions}`}
</Typography>
)}
{hw.deadline && (
<Stack direction="row" alignItems="center" spacing={0.5}>
<Iconify icon="eva:calendar-outline" width={14} color="text.secondary" />
<Typography
variant="caption"
color={hw.is_overdue ? 'error' : 'text.secondary'}
>
{formatDate(hw.deadline)} {formatTime(hw.deadline)}
</Typography>
</Stack>
)}
{hw.student_score?.score != null && (
<Typography variant="caption" color="primary" fontWeight={600} sx={{ display: 'block', mt: 1 }}>
Оценка: {hw.student_score.score} / {hw.max_score || 5}
</Typography>
)}
{hw.ai_draft_count > 0 && userRole === 'mentor' && (
<Chip
label={`ИИ-черновик: ${hw.ai_draft_count}`}
size="small"
color="info"
variant="outlined"
sx={{ mt: 1 }}
/>
)}
</CardContent>
</CardActionArea>
{((userRole === 'client' && status === 'pending' && !hw.is_overdue && hw.status === 'published') ||
(userRole === 'mentor' && isFillLater)) && (
<Box sx={{ px: 2, pb: 2 }}>
{userRole === 'client' && (
<Button
variant="contained"
size="small"
fullWidth
onClick={(e) => { e.stopPropagation(); onSubmit(); }}
>
Сдать ДЗ
</Button>
)}
{userRole === 'mentor' && isFillLater && (
<Button
variant="outlined"
size="small"
fullWidth
color="warning"
onClick={(e) => { e.stopPropagation(); onEdit(); }}
startIcon={<Iconify icon="eva:edit-outline" />}
>
Заполнить задание
</Button>
)}
</Box>
)}
</Card>
);
}
// ----------------------------------------------------------------------
function HomeworkColumn({ title, items, userRole, onView, onSubmit, onEdit, emptyText }) {
return (
<Box sx={{ minWidth: 0 }}>
<Typography
variant="subtitle1"
fontWeight={600}
sx={{ mb: 2, pb: 1, borderBottom: '2px solid', borderColor: 'divider' }}
>
{title}
{items.length > 0 && (
<Typography component="span" variant="body2" color="text.secondary" sx={{ ml: 1 }}>
({items.length})
</Typography>
)}
</Typography>
<Stack spacing={1.5}>
{items.length === 0 ? (
<Typography variant="body2" color="text.secondary">
{emptyText || 'Нет заданий'}
</Typography>
) : (
items.map((hw) => (
<HomeworkCard
key={hw.id}
hw={hw}
userRole={userRole}
onView={() => onView(hw)}
onSubmit={() => onSubmit(hw)}
onEdit={() => onEdit(hw)}
/>
))
)}
</Stack>
</Box>
);
}
// ----------------------------------------------------------------------
export function HomeworkView() {
const { user } = useAuthContext();
const userRole = user?.role ?? '';
const [homework, setHomework] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Drawers
const [selectedHw, setSelectedHw] = useState(null);
const [detailsOpen, setDetailsOpen] = useState(false);
const [submitOpen, setSubmitOpen] = useState(false);
const [submitHwId, setSubmitHwId] = useState(null);
const [editOpen, setEditOpen] = useState(false);
const [editHw, setEditHw] = useState(null);
const loadHomework = useCallback(async () => {
try {
setLoading(true);
const res = await getHomework({ page_size: 1000 });
setHomework(res.results);
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadHomework();
}, [loadHomework]);
const handleViewDetails = useCallback(async (hw) => {
try {
const full = await getHomeworkById(hw.id);
setSelectedHw(full);
} catch {
setSelectedHw(hw);
}
setDetailsOpen(true);
}, []);
const handleOpenSubmit = useCallback((hw) => {
setSubmitHwId(hw.id);
setSubmitOpen(true);
}, []);
const handleOpenEdit = useCallback((hw) => {
setEditHw(hw);
setEditOpen(true);
}, []);
// Categorize
const pending = useMemo(
() => homework.filter((hw) => getHomeworkStatus(hw) === 'pending' && hw.status === 'published'),
[homework]
);
const submitted = useMemo(
() => homework.filter((hw) => getHomeworkStatus(hw) === 'submitted'),
[homework]
);
const returned = useMemo(
() => homework.filter((hw) => getHomeworkStatus(hw) === 'returned'),
[homework]
);
const reviewed = useMemo(
() => homework.filter((hw) => getHomeworkStatus(hw) === 'reviewed'),
[homework]
);
const fillLater = useMemo(
() => (userRole === 'mentor' ? homework.filter((hw) => hw.fill_later === true) : []),
[homework, userRole]
);
const aiDraft = useMemo(
() => (userRole === 'mentor' ? homework.filter((hw) => (hw.ai_draft_count ?? 0) > 0) : []),
[homework, userRole]
);
return (
<DashboardContent>
<CustomBreadcrumbs
heading="Домашние задания"
links={[
{ name: 'Главная', href: paths.dashboard.root },
{ name: 'Домашние задания' },
]}
sx={{ mb: 3 }}
/>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : homework.length === 0 ? (
<Typography variant="body1" color="text.secondary" sx={{ py: 4 }}>
Нет домашних заданий
</Typography>
) : (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: '1fr',
sm: 'repeat(2, 1fr)',
lg: 'repeat(3, 1fr)',
},
gap: 3,
}}
>
{userRole === 'mentor' && fillLater.length > 0 && (
<HomeworkColumn
title="Ожидают заполнения"
items={fillLater}
userRole={userRole}
onView={handleViewDetails}
onSubmit={handleOpenSubmit}
onEdit={handleOpenEdit}
/>
)}
<HomeworkColumn
title="Ожидают сдачи"
items={pending}
userRole={userRole}
onView={handleViewDetails}
onSubmit={handleOpenSubmit}
onEdit={handleOpenEdit}
/>
<HomeworkColumn
title="На проверке"
items={submitted}
userRole={userRole}
onView={handleViewDetails}
onSubmit={handleOpenSubmit}
onEdit={handleOpenEdit}
/>
{userRole === 'mentor' && aiDraft.length > 0 && (
<HomeworkColumn
title="Черновик от ИИ"
items={aiDraft}
userRole={userRole}
onView={handleViewDetails}
onSubmit={handleOpenSubmit}
onEdit={handleOpenEdit}
/>
)}
<HomeworkColumn
title="На доработке"
items={returned}
userRole={userRole}
onView={handleViewDetails}
onSubmit={handleOpenSubmit}
onEdit={handleOpenEdit}
/>
<HomeworkColumn
title="Проверено"
items={reviewed}
userRole={userRole}
onView={handleViewDetails}
onSubmit={handleOpenSubmit}
onEdit={handleOpenEdit}
/>
</Box>
)}
{/* Details Drawer */}
<HomeworkDetailsDrawer
open={detailsOpen}
homework={selectedHw}
userRole={userRole}
onClose={() => { setDetailsOpen(false); setSelectedHw(null); }}
onSuccess={loadHomework}
onOpenSubmit={() => {
setSubmitHwId(selectedHw?.id);
setSubmitOpen(true);
}}
onOpenEdit={(hw) => {
setEditHw(hw);
setEditOpen(true);
}}
/>
{/* Submit Drawer */}
<HomeworkSubmitDrawer
open={submitOpen}
homeworkId={submitHwId}
onClose={() => { setSubmitOpen(false); setSubmitHwId(null); }}
onSuccess={loadHomework}
/>
{/* Edit Draft Drawer */}
<HomeworkEditDrawer
open={editOpen}
homework={editHw}
onClose={() => { setEditOpen(false); setEditHw(null); }}
onSuccess={loadHomework}
/>
</DashboardContent>
);
}

View File

@ -0,0 +1 @@
export * from './homework-view';

View File

@ -0,0 +1 @@
export * from './materials-view';

View File

@ -0,0 +1,411 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack';
import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import Tooltip from '@mui/material/Tooltip';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import DialogTitle from '@mui/material/DialogTitle';
import CardContent from '@mui/material/CardContent';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import InputAdornment from '@mui/material/InputAdornment';
import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths';
import {
getMaterials,
createMaterial,
deleteMaterial,
getMaterialTypeIcon,
} from 'src/utils/materials-api';
import { CONFIG } from 'src/config-global';
import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
import { useAuthContext } from 'src/auth/hooks';
// ----------------------------------------------------------------------
function fileUrl(href) {
if (!href) return '';
if (href.startsWith('http://') || href.startsWith('https://')) return href;
const base = CONFIG.site.serverUrl?.replace('/api', '') || '';
return base + (href.startsWith('/') ? href : `/${href}`);
}
function formatSize(bytes) {
if (!bytes) return '';
if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} МБ`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} КБ`;
return `${bytes} Б`;
}
// ----------------------------------------------------------------------
function MaterialCard({ material, onDelete, isMentor }) {
const icon = getMaterialTypeIcon(material);
const url = fileUrl(material.file_url || material.file || '');
const isImage =
material.material_type === 'image' ||
/\.(jpe?g|png|gif|webp)$/i.test(material.file_name || material.file || '');
return (
<Card variant="outlined" sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
{isImage && url ? (
<Box
component="a"
href={url}
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'block',
height: 140,
overflow: 'hidden',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Box
component="img"
src={url}
alt={material.title}
sx={{ width: '100%', height: '100%', objectFit: 'cover' }}
/>
</Box>
) : (
<Box
sx={{
height: 100,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'background.neutral',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<Iconify icon={icon} width={40} color="primary.main" />
</Box>
)}
<CardContent sx={{ flex: 1, pb: 1 }}>
<Typography variant="subtitle2" noWrap sx={{ mb: 0.5 }}>
{material.title}
</Typography>
{material.description && (
<Typography
variant="caption"
color="text.secondary"
sx={{
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{material.description}
</Typography>
)}
{(material.file_size || material.category_name) && (
<Stack direction="row" spacing={1} sx={{ mt: 1 }}>
{material.category_name && (
<Typography variant="caption" color="text.disabled">
{material.category_name}
</Typography>
)}
{material.file_size && (
<Typography variant="caption" color="text.disabled">
{formatSize(material.file_size)}
</Typography>
)}
</Stack>
)}
</CardContent>
<Stack
direction="row"
spacing={0.5}
sx={{ px: 1.5, pb: 1.5, borderTop: '1px solid', borderColor: 'divider', pt: 1 }}
>
{url && (
<Tooltip title="Скачать">
<IconButton size="small" component="a" href={url} target="_blank" rel="noopener noreferrer">
<Iconify icon="eva:download-outline" width={18} />
</IconButton>
</Tooltip>
)}
{isMentor && (
<Tooltip title="Удалить">
<IconButton size="small" color="error" onClick={() => onDelete(material)}>
<Iconify icon="eva:trash-2-outline" width={18} />
</IconButton>
</Tooltip>
)}
</Stack>
</Card>
);
}
// ----------------------------------------------------------------------
function UploadDialog({ open, onClose, onSuccess }) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [file, setFile] = useState(null);
const [isPublic, setIsPublic] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const reset = () => {
setTitle('');
setDescription('');
setFile(null);
setIsPublic(false);
setError(null);
};
const handleClose = () => {
if (!loading) {
reset();
onClose();
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!title.trim()) {
setError('Укажите название');
return;
}
if (!file) {
setError('Выберите файл');
return;
}
try {
setLoading(true);
setError(null);
await createMaterial({ title: title.trim(), description: description.trim(), file, is_public: isPublic });
await onSuccess();
handleClose();
} catch (err) {
setError(err?.response?.data?.detail || err?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={handleClose} maxWidth="sm" fullWidth>
<DialogTitle>Добавить материал</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Название *"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={loading}
fullWidth
/>
<TextField
label="Описание"
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={loading}
multiline
rows={2}
fullWidth
/>
<Box>
<input
type="file"
id="material-file"
onChange={(e) => setFile(e.target.files?.[0] || null)}
disabled={loading}
style={{ display: 'none' }}
/>
<Button
component="label"
htmlFor="material-file"
variant="outlined"
startIcon={<Iconify icon="eva:upload-outline" />}
fullWidth
disabled={loading}
sx={{ py: 1.5, border: '2px dashed', '&:hover': { border: '2px dashed' } }}
>
{file ? file.name : 'Выбрать файл'}
</Button>
</Box>
{error && <Alert severity="error">{error}</Alert>}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={handleClose} disabled={loading}>
Отмена
</Button>
<Button
variant="contained"
onClick={handleSubmit}
disabled={loading || !title.trim() || !file}
startIcon={loading ? <Iconify icon="svg-spinners:8-dots-rotate" /> : null}
>
{loading ? 'Загрузка...' : 'Сохранить'}
</Button>
</DialogActions>
</Dialog>
);
}
// ----------------------------------------------------------------------
export function MaterialsView() {
const { user } = useAuthContext();
const isMentor = user?.role === 'mentor';
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [search, setSearch] = useState('');
const [uploadOpen, setUploadOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleting, setDeleting] = useState(false);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await getMaterials({ page_size: 200 });
setMaterials(res.results);
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const handleDelete = async () => {
if (!deleteTarget) return;
try {
setDeleting(true);
await deleteMaterial(deleteTarget.id);
setMaterials((prev) => prev.filter((m) => m.id !== deleteTarget.id));
setDeleteTarget(null);
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка удаления');
} finally {
setDeleting(false);
}
};
const filtered = materials.filter((m) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
return (
(m.title || '').toLowerCase().includes(q) ||
(m.description || '').toLowerCase().includes(q) ||
(m.category_name || '').toLowerCase().includes(q)
);
});
return (
<DashboardContent>
<CustomBreadcrumbs
heading="Материалы"
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Материалы' }]}
action={
isMentor && (
<Button
variant="contained"
startIcon={<Iconify icon="eva:plus-fill" />}
onClick={() => setUploadOpen(true)}
>
Добавить
</Button>
)
}
sx={{ mb: 3 }}
/>
<Stack direction="row" spacing={2} sx={{ mb: 3 }}>
<TextField
placeholder="Поиск материалов..."
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
sx={{ width: 300 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Iconify icon="eva:search-outline" />
</InputAdornment>
),
}}
/>
</Stack>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : filtered.length === 0 ? (
<Typography variant="body1" color="text.secondary" sx={{ py: 4 }}>
{search ? 'Ничего не найдено' : 'Нет материалов'}
</Typography>
) : (
<Grid container spacing={2}>
{filtered.map((material) => (
<Grid key={material.id} item xs={12} sm={6} md={4} lg={3}>
<MaterialCard
material={material}
isMentor={isMentor}
onDelete={setDeleteTarget}
/>
</Grid>
))}
</Grid>
)}
{/* Upload */}
<UploadDialog open={uploadOpen} onClose={() => setUploadOpen(false)} onSuccess={load} />
{/* Delete confirm */}
<Dialog open={!!deleteTarget} onClose={() => !deleting && setDeleteTarget(null)} maxWidth="xs" fullWidth>
<DialogTitle>Удалить материал?</DialogTitle>
<DialogContent>
<Typography>
&laquo;{deleteTarget?.title}&raquo; будет удалён безвозвратно.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)} disabled={deleting}>
Отмена
</Button>
<Button variant="contained" color="error" onClick={handleDelete} disabled={deleting}>
{deleting ? 'Удаление...' : 'Удалить'}
</Button>
</DialogActions>
</Dialog>
</DashboardContent>
);
}

View File

@ -0,0 +1 @@
export * from './notifications-view';

View File

@ -0,0 +1,244 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Box from '@mui/material/Box';
import Tab from '@mui/material/Tab';
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 Badge from '@mui/material/Badge';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import Tooltip from '@mui/material/Tooltip';
import ListItem from '@mui/material/ListItem';
import Typography from '@mui/material/Typography';
import IconButton from '@mui/material/IconButton';
import ListItemText from '@mui/material/ListItemText';
import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths';
import {
markAsRead,
markAllAsRead,
getNotifications,
deleteNotification,
} from 'src/utils/notifications-api';
import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
// ----------------------------------------------------------------------
function formatDate(s) {
if (!s) return '';
const d = new Date(s);
return Number.isNaN(d.getTime())
? ''
: d.toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
}
function notifIcon(type) {
switch (type) {
case 'success': return 'eva:checkmark-circle-2-outline';
case 'warning': return 'eva:alert-triangle-outline';
case 'error': return 'eva:close-circle-outline';
default: return 'eva:bell-outline';
}
}
function notifColor(type) {
switch (type) {
case 'success': return 'success.main';
case 'warning': return 'warning.main';
case 'error': return 'error.main';
default: return 'info.main';
}
}
// ----------------------------------------------------------------------
export function NotificationsView() {
const [notifications, setNotifications] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [tab, setTab] = useState('unread');
const [processing, setProcessing] = useState(null);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await getNotifications({ page_size: 100 });
setNotifications(res.results);
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const filtered = notifications.filter((n) =>
tab === 'all' ? true : !n.is_read
);
const unreadCount = notifications.filter((n) => !n.is_read).length;
const handleMarkAsRead = async (id) => {
try {
setProcessing(id);
await markAsRead(id);
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, is_read: true } : n));
} catch {
// ignore
} finally {
setProcessing(null);
}
};
const handleMarkAll = async () => {
try {
await markAllAsRead();
setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true })));
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка');
}
};
const handleDelete = async (id) => {
try {
setProcessing(id);
await deleteNotification(id);
setNotifications((prev) => prev.filter((n) => n.id !== id));
} catch {
// ignore
} finally {
setProcessing(null);
}
};
return (
<DashboardContent>
<CustomBreadcrumbs
heading="Уведомления"
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: 'Уведомления' }]}
action={
unreadCount > 0 && (
<Button variant="outlined" size="small" onClick={handleMarkAll}>
Отметить все как прочитанные
</Button>
)
}
sx={{ mb: 3 }}
/>
{error && <Alert severity="error" sx={{ mb: 3 }}>{error}</Alert>}
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
<Tab value="unread" label={
<Badge badgeContent={unreadCount} color="error">
<Box sx={{ pr: unreadCount > 0 ? 1.5 : 0 }}>Непрочитанные</Box>
</Badge>
} />
<Tab value="all" label="Все" />
</Tabs>
{loading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}>
<CircularProgress />
</Box>
) : filtered.length === 0 ? (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Iconify icon="eva:bell-off-outline" width={64} color="text.disabled" sx={{ mb: 2 }} />
<Typography variant="body1" color="text.secondary">
{tab === 'unread' ? 'Нет непрочитанных уведомлений' : 'Нет уведомлений'}
</Typography>
</Box>
) : (
<List disablePadding sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, overflow: 'hidden' }}>
{filtered.map((n, idx) => (
<Box key={n.id}>
<ListItem
sx={{
alignItems: 'flex-start',
bgcolor: n.is_read ? 'transparent' : 'action.selected',
'&:hover': { bgcolor: 'action.hover' },
py: 2,
}}
secondaryAction={
<Stack direction="row" spacing={0.5}>
{!n.is_read && (
<Tooltip title="Отметить прочитанным">
<IconButton
size="small"
onClick={() => handleMarkAsRead(n.id)}
disabled={processing === n.id}
>
<Iconify icon="eva:checkmark-outline" width={18} />
</IconButton>
</Tooltip>
)}
<Tooltip title="Удалить">
<IconButton
size="small"
color="error"
onClick={() => handleDelete(n.id)}
disabled={processing === n.id}
>
<Iconify icon="eva:trash-2-outline" width={18} />
</IconButton>
</Tooltip>
</Stack>
}
>
<Box sx={{ mr: 2, mt: 0.5 }}>
<Iconify
icon={notifIcon(n.type || n.notification_type)}
width={22}
color={notifColor(n.type || n.notification_type)}
/>
</Box>
<ListItemText
primary={
<Stack direction="row" alignItems="center" spacing={1}>
<Typography variant="subtitle2">
{n.title || 'Уведомление'}
</Typography>
{!n.is_read && (
<Box
sx={{
width: 8,
height: 8,
borderRadius: '50%',
bgcolor: 'primary.main',
flexShrink: 0,
}}
/>
)}
</Stack>
}
secondary={
<>
<Typography variant="body2" color="text.secondary" component="span" sx={{ display: 'block' }}>
{n.message}
</Typography>
<Typography variant="caption" color="text.disabled" component="span">
{formatDate(n.created_at)}
</Typography>
</>
}
/>
</ListItem>
{idx < filtered.length - 1 && <Divider />}
</Box>
))}
</List>
)}
</DashboardContent>
);
}

View File

@ -0,0 +1 @@
export * from './students-view';

View File

@ -0,0 +1,517 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import Tab from '@mui/material/Tab';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
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 Avatar from '@mui/material/Avatar';
import Button from '@mui/material/Button';
import Dialog from '@mui/material/Dialog';
import Divider from '@mui/material/Divider';
import ListItem from '@mui/material/ListItem';
import TextField from '@mui/material/TextField';
import Typography from '@mui/material/Typography';
import CardHeader from '@mui/material/CardHeader';
import DialogTitle from '@mui/material/DialogTitle';
import CardContent from '@mui/material/CardContent';
import ListItemText from '@mui/material/ListItemText';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import InputAdornment from '@mui/material/InputAdornment';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import CircularProgress from '@mui/material/CircularProgress';
import { paths } from 'src/routes/paths';
import {
getStudents,
getMyMentors,
getMyInvitations,
addStudentInvitation,
sendMentorshipRequest,
acceptMentorshipRequest,
rejectMentorshipRequest,
rejectInvitationAsStudent,
confirmInvitationAsStudent,
getMentorshipRequestsPending,
} from 'src/utils/students-api';
import { CONFIG } from 'src/config-global';
import { DashboardContent } from 'src/layouts/dashboard';
import { Iconify } from 'src/components/iconify';
import { CustomBreadcrumbs } from 'src/components/custom-breadcrumbs';
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}`);
}
function initials(firstName, lastName) {
return `${(firstName || '')[0] || ''}${(lastName || '')[0] || ''}`.toUpperCase();
}
// ----------------------------------------------------------------------
// MENTOR VIEWS
function MentorStudentList({ onRefresh }) {
const [students, setStudents] = useState([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [error, setError] = useState(null);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await getStudents({ page_size: 200 });
setStudents(res.results);
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const filtered = students.filter((s) => {
if (!search.trim()) return true;
const q = search.toLowerCase();
const u = s.user || {};
return (
(u.first_name || '').toLowerCase().includes(q) ||
(u.last_name || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q)
);
});
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}><CircularProgress /></Box>;
return (
<Stack spacing={2}>
<TextField
placeholder="Поиск учеников..."
value={search}
onChange={(e) => setSearch(e.target.value)}
size="small"
InputProps={{
startAdornment: <InputAdornment position="start"><Iconify icon="eva:search-outline" /></InputAdornment>,
}}
/>
{error && <Alert severity="error">{error}</Alert>}
{filtered.length === 0 ? (
<Typography variant="body2" color="text.secondary">
{search ? 'Ничего не найдено' : 'Нет учеников'}
</Typography>
) : (
<List disablePadding>
{filtered.map((s) => {
const u = s.user || {};
return (
<ListItem
key={s.id}
divider
sx={{ borderRadius: 1, '&:hover': { bgcolor: 'action.hover' } }}
>
<ListItemAvatar>
<Avatar src={avatarUrl(u.avatar_url || u.avatar)}>
{initials(u.first_name, u.last_name)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={`${u.first_name || ''} ${u.last_name || ''}`.trim() || u.email}
secondary={u.email}
/>
{s.total_lessons != null && (
<Typography variant="caption" color="text.secondary">
{s.total_lessons} уроков
</Typography>
)}
</ListItem>
);
})}
</List>
)}
</Stack>
);
}
function MentorRequests({ onRefresh }) {
const [requests, setRequests] = useState([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(null);
const [error, setError] = useState(null);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await getMentorshipRequestsPending();
setRequests(Array.isArray(res) ? res : []);
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка загрузки');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handle = async (id, action) => {
try {
setProcessing(id);
if (action === 'accept') await acceptMentorshipRequest(id);
else await rejectMentorshipRequest(id);
await load();
onRefresh();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка');
} finally {
setProcessing(null);
}
};
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}><CircularProgress /></Box>;
return (
<Stack spacing={2}>
{error && <Alert severity="error">{error}</Alert>}
{requests.length === 0 ? (
<Typography variant="body2" color="text.secondary">Нет входящих заявок</Typography>
) : (
<List disablePadding>
{requests.map((r) => {
const s = r.student || {};
return (
<ListItem
key={r.id}
divider
secondaryAction={
<Stack direction="row" spacing={1}>
<Button
size="small"
variant="contained"
color="success"
onClick={() => handle(r.id, 'accept')}
disabled={processing === r.id}
>
Принять
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handle(r.id, 'reject')}
disabled={processing === r.id}
>
Отклонить
</Button>
</Stack>
}
>
<ListItemAvatar>
<Avatar src={avatarUrl(s.avatar)}>
{initials(s.first_name, s.last_name)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={`${s.first_name || ''} ${s.last_name || ''}`.trim() || s.email}
secondary={s.email}
/>
</ListItem>
);
})}
</List>
)}
</Stack>
);
}
function InviteDialog({ open, onClose, onSuccess }) {
const [mode, setMode] = useState('email'); // 'email' | 'code'
const [value, setValue] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
const reset = () => { setValue(''); setError(null); setSuccessMsg(null); };
const handleSend = async () => {
if (!value.trim()) return;
try {
setLoading(true);
setError(null);
const payload = mode === 'email' ? { email: value.trim() } : { universal_code: value.trim() };
const res = await addStudentInvitation(payload);
setSuccessMsg(res?.message || 'Приглашение отправлено');
onSuccess();
} catch (e) {
setError(e?.response?.data?.detail || e?.response?.data?.email?.[0] || e?.message || 'Ошибка');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={() => { if (!loading) { reset(); onClose(); } }} maxWidth="xs" fullWidth>
<DialogTitle>Пригласить ученика</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Tabs value={mode} onChange={(_, v) => setMode(v)} size="small">
<Tab value="email" label="По email" />
<Tab value="code" label="По коду" />
</Tabs>
<TextField
label={mode === 'email' ? 'Email ученика' : 'Код приглашения'}
value={value}
onChange={(e) => setValue(e.target.value)}
disabled={loading}
fullWidth
size="small"
/>
{error && <Alert severity="error">{error}</Alert>}
{successMsg && <Alert severity="success">{successMsg}</Alert>}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => { reset(); onClose(); }} disabled={loading}>Закрыть</Button>
<Button variant="contained" onClick={handleSend} disabled={loading || !value.trim()}>
{loading ? 'Отправка...' : 'Пригласить'}
</Button>
</DialogActions>
</Dialog>
);
}
// ----------------------------------------------------------------------
// CLIENT VIEWS
function ClientMentorList() {
const [mentors, setMentors] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getMyMentors()
.then((list) => setMentors(Array.isArray(list) ? list : []))
.catch(() => setMentors([]))
.finally(() => setLoading(false));
}, []);
if (loading) return <Box sx={{ display: 'flex', justifyContent: 'center', py: 3 }}><CircularProgress /></Box>;
return (
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ mb: 1 }}>Мои менторы</Typography>
{mentors.length === 0 ? (
<Typography variant="body2" color="text.secondary">Нет подключённых менторов</Typography>
) : (
<List disablePadding>
{mentors.map((m) => (
<ListItem key={m.id} divider>
<ListItemAvatar>
<Avatar src={avatarUrl(m.avatar_url)}>
{initials(m.first_name, m.last_name)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={`${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email}
secondary={m.email}
/>
</ListItem>
))}
</List>
)}
</Stack>
);
}
function ClientInvitations({ onRefresh }) {
const [invitations, setInvitations] = useState([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(null);
const [error, setError] = useState(null);
const load = useCallback(async () => {
try {
setLoading(true);
const res = await getMyInvitations();
setInvitations(Array.isArray(res) ? res.filter((i) => i.status === 'pending') : []);
} catch {
setInvitations([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const handle = async (id, action) => {
try {
setProcessing(id);
if (action === 'confirm') await confirmInvitationAsStudent(id);
else await rejectInvitationAsStudent(id);
await load();
onRefresh();
} catch (e) {
setError(e?.response?.data?.detail || e?.message || 'Ошибка');
} finally {
setProcessing(null);
}
};
if (loading) return null;
if (invitations.length === 0) return null;
return (
<Card variant="outlined" sx={{ mb: 3 }}>
<CardHeader title="Приглашения от менторов" titleTypographyProps={{ variant: 'subtitle1' }} />
<Divider />
<CardContent sx={{ pt: 1 }}>
{error && <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert>}
<List disablePadding>
{invitations.map((inv) => {
const m = inv.mentor || {};
return (
<ListItem
key={inv.id}
divider
secondaryAction={
<Stack direction="row" spacing={1}>
<Button size="small" variant="contained" color="success" onClick={() => handle(inv.id, 'confirm')} disabled={processing === inv.id}>
Принять
</Button>
<Button size="small" variant="outlined" color="error" onClick={() => handle(inv.id, 'reject')} disabled={processing === inv.id}>
Отклонить
</Button>
</Stack>
}
>
<ListItemAvatar>
<Avatar>{initials(m.first_name, m.last_name)}</Avatar>
</ListItemAvatar>
<ListItemText
primary={`${m.first_name || ''} ${m.last_name || ''}`.trim() || m.email}
secondary={m.email}
/>
</ListItem>
);
})}
</List>
</CardContent>
</Card>
);
}
function SendRequestDialog({ open, onClose, onSuccess }) {
const [code, setCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [successMsg, setSuccessMsg] = useState(null);
const handleSend = async () => {
if (!code.trim()) return;
try {
setLoading(true);
setError(null);
const res = await sendMentorshipRequest(code.trim());
setSuccessMsg(res?.message || 'Заявка отправлена');
onSuccess();
} catch (e) {
setError(e?.response?.data?.detail || e?.response?.data?.mentor_code?.[0] || e?.message || 'Ошибка');
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onClose={() => { if (!loading) { setCode(''); setError(null); setSuccessMsg(null); onClose(); } }} maxWidth="xs" fullWidth>
<DialogTitle>Найти ментора</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<TextField
label="Код ментора"
value={code}
onChange={(e) => setCode(e.target.value.toUpperCase())}
disabled={loading}
fullWidth
size="small"
placeholder="XXXXXXXX"
/>
{error && <Alert severity="error">{error}</Alert>}
{successMsg && <Alert severity="success">{successMsg}</Alert>}
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => { setCode(''); setError(null); setSuccessMsg(null); onClose(); }} disabled={loading}>Закрыть</Button>
<Button variant="contained" onClick={handleSend} disabled={loading || !code.trim()}>
{loading ? 'Отправка...' : 'Отправить заявку'}
</Button>
</DialogActions>
</Dialog>
);
}
// ----------------------------------------------------------------------
export function StudentsView() {
const { user } = useAuthContext();
const isMentor = user?.role === 'mentor';
const [tab, setTab] = useState(0);
const [inviteOpen, setInviteOpen] = useState(false);
const [requestOpen, setRequestOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const refresh = () => setRefreshKey((k) => k + 1);
return (
<DashboardContent>
<CustomBreadcrumbs
heading={isMentor ? 'Ученики' : 'Мои менторы'}
links={[{ name: 'Главная', href: paths.dashboard.root }, { name: isMentor ? 'Ученики' : 'Менторы' }]}
action={
isMentor ? (
<Button variant="contained" startIcon={<Iconify icon="eva:person-add-outline" />} onClick={() => setInviteOpen(true)}>
Пригласить ученика
</Button>
) : (
<Button variant="contained" startIcon={<Iconify icon="eva:search-outline" />} onClick={() => setRequestOpen(true)}>
Найти ментора
</Button>
)
}
sx={{ mb: 3 }}
/>
{isMentor ? (
<>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 3 }}>
<Tab label="Мои ученики" />
<Tab label="Входящие заявки" />
</Tabs>
{tab === 0 && <MentorStudentList key={refreshKey} onRefresh={refresh} />}
{tab === 1 && <MentorRequests key={refreshKey} onRefresh={refresh} />}
<InviteDialog open={inviteOpen} onClose={() => setInviteOpen(false)} onSuccess={refresh} />
</>
) : (
<>
<ClientInvitations key={refreshKey} onRefresh={refresh} />
<ClientMentorList key={refreshKey} />
<SendRequestDialog open={requestOpen} onClose={() => setRequestOpen(false)} onSuccess={refresh} />
</>
)}
</DashboardContent>
);
}

View File

@ -0,0 +1,135 @@
import axios from 'src/utils/axios';
// ----------------------------------------------------------------------
export async function getHomework(params) {
const q = new URLSearchParams();
if (params?.status) q.append('status', params.status);
if (params?.page_size) q.append('page_size', String(params.page_size || 1000));
if (params?.child_id) q.append('child_id', params.child_id);
const query = q.toString();
const url = `/homework/homeworks/${query ? `?${query}` : ''}`;
const res = await axios.get(url);
const {data} = res;
if (Array.isArray(data)) return { results: data, count: data.length };
return { results: data?.results ?? [], count: data?.count ?? 0 };
}
export async function getHomeworkById(id) {
const res = await axios.get(`/homework/homeworks/${id}/`);
return res.data;
}
export async function createHomework(data) {
const payload = { max_score: 5, passing_score: 1, ...data };
const res = await axios.post('/homework/homeworks/', payload);
return res.data;
}
export async function updateHomework(id, data) {
const res = await axios.patch(`/homework/homeworks/${id}/`, data);
return res.data;
}
export async function publishHomework(id) {
const res = await axios.post(`/homework/homeworks/${id}/publish/`);
return res.data;
}
export async function getHomeworkSubmissions(homeworkId, options) {
const params = new URLSearchParams({ homework_id: String(homeworkId) });
if (options?.child_id) params.append('child_id', options.child_id);
const res = await axios.get(`/homework/submissions/?${params.toString()}`);
const {data} = res;
if (Array.isArray(data)) return data;
return data?.results ?? [];
}
export async function getMySubmission(homeworkId, options) {
const list = await getHomeworkSubmissions(homeworkId, options);
return list.length > 0 ? list[0] : null;
}
export async function getHomeworkSubmission(submissionId) {
const res = await axios.get(`/homework/submissions/${submissionId}/`);
return res.data;
}
export async function gradeSubmission(submissionId, data) {
const res = await axios.post(`/homework/submissions/${submissionId}/grade/`, data);
return res.data;
}
export async function checkSubmissionWithAi(submissionId) {
const res = await axios.post(`/homework/submissions/${submissionId}/check_with_ai/`);
return res.data;
}
export async function returnSubmissionForRevision(submissionId, feedback) {
const res = await axios.post(`/homework/submissions/${submissionId}/return_for_revision/`, {
feedback,
});
return res.data;
}
export async function deleteSubmission(submissionId) {
await axios.delete(`/homework/submissions/${submissionId}/`);
}
export async function submitHomework(homeworkId, data, onUploadProgress) {
const hasFiles = data.files && data.files.length > 0;
if (hasFiles) {
const formData = new FormData();
formData.append('homework_id', String(homeworkId));
if (data.content) formData.append('content', data.content);
data.files.forEach((f) => formData.append('attachment', f));
const res = await axios.post('/homework/submissions/', formData, {
onUploadProgress:
onUploadProgress &&
((event) => {
if (event.total && event.total > 0) {
onUploadProgress(Math.min(Math.round((event.loaded / event.total) * 100), 100));
}
}),
});
return res.data;
}
const res = await axios.post('/homework/submissions/', {
homework_id: homeworkId,
content: data.content || '',
});
return res.data;
}
export async function uploadHomeworkFile(homeworkId, file) {
const formData = new FormData();
formData.append('homework', String(homeworkId));
formData.append('file_type', 'assignment');
formData.append('file', file);
const res = await axios.post('/homework/files/', formData);
return res.data;
}
export async function deleteHomeworkFile(fileId) {
await axios.delete(`/homework/files/${fileId}/`);
}
// Helpers
export function validateHomeworkFiles(files) {
const MAX_FILES = 10;
const MAX_SIZE = 50 * 1024 * 1024;
if (files.length > MAX_FILES) return { valid: false, error: `Максимум ${MAX_FILES} файлов` };
const oversized = files.find((f) => f.size > MAX_SIZE);
if (oversized) return { valid: false, error: `Файл "${oversized.name}" больше 50 МБ` };
return { valid: true };
}
export function getHomeworkStatus(hw) {
if (hw.status !== 'published') return 'pending';
if (hw.checked_submissions > 0 && hw.checked_submissions === hw.total_submissions)
return 'reviewed';
if (hw.returned_submissions > 0 && hw.returned_submissions === hw.total_submissions)
return 'returned';
if (hw.total_submissions > 0) return 'submitted';
return 'pending';
}

View File

@ -0,0 +1,71 @@
import axios from 'src/utils/axios';
// ----------------------------------------------------------------------
export async function getMaterials(params) {
const res = await axios.get('/materials/materials/', { params });
const {data} = res;
if (Array.isArray(data)) return { results: data, count: data.length };
return { results: data?.results ?? [], count: data?.count ?? 0 };
}
export async function getMyMaterials() {
const res = await axios.get('/materials/materials/my_materials/');
const {data} = res;
if (Array.isArray(data)) return data;
return data?.results ?? [];
}
export async function getMaterialById(id) {
const res = await axios.get(`/materials/materials/${id}/`);
return res.data;
}
export async function createMaterial(data) {
const formData = new FormData();
formData.append('title', data.title);
if (data.description) formData.append('description', data.description);
if (data.file) formData.append('file', data.file);
if (data.category) formData.append('category', String(data.category));
if (data.is_public !== undefined) formData.append('is_public', String(data.is_public));
const res = await axios.post('/materials/materials/', formData);
return res.data;
}
export async function updateMaterial(id, data) {
const formData = new FormData();
if (data.title) formData.append('title', data.title);
if (data.description !== undefined) formData.append('description', data.description);
if (data.file) formData.append('file', data.file);
if (data.category) formData.append('category', String(data.category));
if (data.is_public !== undefined) formData.append('is_public', String(data.is_public));
const res = await axios.patch(`/materials/materials/${id}/`, formData);
return res.data;
}
export async function deleteMaterial(id) {
await axios.delete(`/materials/materials/${id}/`);
}
export async function shareMaterial(id, userIds) {
await axios.post(`/materials/materials/${id}/share/`, { user_ids: userIds });
}
export async function getMaterialCategories() {
const res = await axios.get('/materials/categories/');
return res.data;
}
// Helper: icon by type
export function getMaterialTypeIcon(material) {
const type = material?.material_type;
const mime = (material?.file_type || '').toLowerCase();
const name = material?.file_name || material?.file || '';
if (type === 'image' || mime.startsWith('image/') || /\.(jpe?g|png|gif|webp|bmp|svg)$/i.test(name)) return 'eva:image-outline';
if (type === 'video' || mime.startsWith('video/') || /\.(mp4|webm|mov|avi)$/i.test(name)) return 'eva:video-outline';
if (type === 'audio' || mime.startsWith('audio/') || /\.(mp3|wav|ogg|m4a)$/i.test(name)) return 'eva:headphones-outline';
if (type === 'document' || mime.includes('pdf') || /\.(pdf|docx?|odt)$/i.test(name)) return 'eva:file-text-outline';
if (type === 'presentation' || /\.(pptx?|odp)$/i.test(name)) return 'eva:monitor-outline';
if (type === 'archive' || /\.(zip|rar|7z|tar)$/i.test(name)) return 'eva:archive-outline';
return 'eva:file-outline';
}

View File

@ -0,0 +1,29 @@
import axios from 'src/utils/axios';
export async function getNotifications(params) {
const res = await axios.get('/notifications/', { params });
const {data} = res;
if (Array.isArray(data)) return { results: data, count: data.length };
return { results: data?.results ?? [], count: data?.count ?? 0 };
}
export async function getUnreadNotifications() {
const res = await axios.get('/notifications/unread/');
const data = res.data ?? {};
return {
data: data.data ?? [],
count: data.count ?? 0,
};
}
export async function markAsRead(id) {
await axios.post(`/notifications/${id}/mark_as_read/`);
}
export async function markAllAsRead() {
await axios.post('/notifications/mark_all_as_read/');
}
export async function deleteNotification(id) {
await axios.delete(`/notifications/${id}/`);
}

View File

@ -0,0 +1,67 @@
import axios from 'src/utils/axios';
// Students (mentor's clients)
export async function getStudents(params) {
const res = await axios.get('/manage/clients/', { params });
const {data} = res;
if (Array.isArray(data)) return { results: data, count: data.length };
return { results: data?.results ?? [], count: data?.count ?? 0 };
}
// Invite student by email or universal_code
export async function addStudentInvitation(payload) {
const res = await axios.post('/manage/clients/add_client/', payload);
return res.data;
}
// Generate invitation link for mentor
export async function generateInvitationLink() {
const res = await axios.post('/manage/clients/generate-invitation-link/');
return res.data;
}
// Pending mentorship requests for mentor
export async function getMentorshipRequestsPending() {
const res = await axios.get('/mentorship-requests/pending/');
return res.data;
}
export async function acceptMentorshipRequest(id) {
const res = await axios.post(`/mentorship-requests/${id}/accept/`);
return res.data;
}
export async function rejectMentorshipRequest(id) {
const res = await axios.post(`/mentorship-requests/${id}/reject/`);
return res.data;
}
// Client: send mentorship request to mentor by code
export async function sendMentorshipRequest(mentorCode) {
const res = await axios.post('/mentorship-requests/send/', {
mentor_code: mentorCode.trim().toUpperCase(),
});
return res.data;
}
// Client: my mentors
export async function getMyMentors() {
const res = await axios.get('/mentorship-requests/my-mentors/');
return res.data;
}
// Client: incoming invitations from mentors
export async function getMyInvitations() {
const res = await axios.get('/invitation/my-invitations/');
return res.data;
}
export async function confirmInvitationAsStudent(invitationId) {
const res = await axios.post('/invitation/confirm-as-student/', { invitation_id: invitationId });
return res.data;
}
export async function rejectInvitationAsStudent(invitationId) {
const res = await axios.post('/invitation/reject-as-student/', { invitation_id: invitationId });
return res.data;
}