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:
parent
17bed2b321
commit
d4ec417ebf
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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 />;
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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') },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './homework-view';
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './materials-view';
|
||||
|
|
@ -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>
|
||||
«{deleteTarget?.title}» будет удалён безвозвратно.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteTarget(null)} disabled={deleting}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="contained" color="error" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting ? 'Удаление...' : 'Удалить'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</DashboardContent>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './notifications-view';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from './students-view';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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}/`);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue