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';
|
'use client';
|
||||||
|
|
||||||
import { m } from 'framer-motion';
|
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 Box from '@mui/material/Box';
|
||||||
|
import Tab from '@mui/material/Tab';
|
||||||
|
import List from '@mui/material/List';
|
||||||
import Stack from '@mui/material/Stack';
|
import Stack from '@mui/material/Stack';
|
||||||
import Badge from '@mui/material/Badge';
|
import Badge from '@mui/material/Badge';
|
||||||
import Drawer from '@mui/material/Drawer';
|
import Drawer from '@mui/material/Drawer';
|
||||||
import Button from '@mui/material/Button';
|
import Button from '@mui/material/Button';
|
||||||
|
import Divider from '@mui/material/Divider';
|
||||||
import SvgIcon from '@mui/material/SvgIcon';
|
import SvgIcon from '@mui/material/SvgIcon';
|
||||||
import Tooltip from '@mui/material/Tooltip';
|
import Tooltip from '@mui/material/Tooltip';
|
||||||
|
import ListItem from '@mui/material/ListItem';
|
||||||
import Typography from '@mui/material/Typography';
|
import Typography from '@mui/material/Typography';
|
||||||
import IconButton from '@mui/material/IconButton';
|
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 { useBoolean } from 'src/hooks/use-boolean';
|
||||||
|
|
||||||
|
import {
|
||||||
|
markAsRead,
|
||||||
|
markAllAsRead,
|
||||||
|
getNotifications,
|
||||||
|
} from 'src/utils/notifications-api';
|
||||||
|
|
||||||
import { Label } from 'src/components/label';
|
import { Label } from 'src/components/label';
|
||||||
import { Iconify } from 'src/components/iconify';
|
import { Iconify } from 'src/components/iconify';
|
||||||
import { varHover } from 'src/components/animate';
|
import { varHover } from 'src/components/animate';
|
||||||
import { Scrollbar } from 'src/components/scrollbar';
|
import { Scrollbar } from 'src/components/scrollbar';
|
||||||
import { CustomTabs } from 'src/components/custom-tabs';
|
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 = [
|
export function NotificationsDrawer({ sx, ...other }) {
|
||||||
{ value: 'all', label: 'All', count: 22 },
|
|
||||||
{ value: 'unread', label: 'Unread', count: 12 },
|
|
||||||
{ value: 'archived', label: 'Archived', count: 10 },
|
|
||||||
];
|
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
|
||||||
|
|
||||||
export function NotificationsDrawer({ data = [], sx, ...other }) {
|
|
||||||
const drawer = useBoolean();
|
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 load = useCallback(async () => {
|
||||||
|
try {
|
||||||
const handleChangeTab = useCallback((event, newValue) => {
|
setLoading(true);
|
||||||
setCurrentTab(newValue);
|
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 = () => {
|
const filtered =
|
||||||
setNotifications(notifications.map((notification) => ({ ...notification, isUnRead: false })));
|
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 = (
|
const handleMarkOne = async (id) => {
|
||||||
<Stack direction="row" alignItems="center" sx={{ py: 2, pl: 2.5, pr: 1, minHeight: 68 }}>
|
try {
|
||||||
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
await markAsRead(id);
|
||||||
Notifications
|
setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, is_read: true } : n));
|
||||||
</Typography>
|
} catch {
|
||||||
|
// ignore
|
||||||
{!!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'
|
|
||||||
}
|
}
|
||||||
>
|
};
|
||||||
{tab.count}
|
|
||||||
</Label>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</CustomTabs>
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderList = (
|
const TABS = [
|
||||||
<Scrollbar>
|
{ value: 'unread', label: 'Непрочитанные', count: unreadCount },
|
||||||
<Box component="ul">
|
{ value: 'all', label: 'Все', count: notifications.length },
|
||||||
{notifications?.map((notification) => (
|
];
|
||||||
<Box component="li" key={notification.id} sx={{ display: 'flex' }}>
|
|
||||||
<NotificationItem notification={notification} />
|
|
||||||
</Box>
|
|
||||||
))}
|
|
||||||
</Box>
|
|
||||||
</Scrollbar>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
@ -123,9 +129,8 @@ export function NotificationsDrawer({ data = [], sx, ...other }) {
|
||||||
sx={sx}
|
sx={sx}
|
||||||
{...other}
|
{...other}
|
||||||
>
|
>
|
||||||
<Badge badgeContent={totalUnRead} color="error">
|
<Badge badgeContent={unreadCount} color="error">
|
||||||
<SvgIcon>
|
<SvgIcon>
|
||||||
{/* https://icon-sets.iconify.design/solar/bell-bing-bold-duotone/ */}
|
|
||||||
<path
|
<path
|
||||||
fill="currentColor"
|
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"
|
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 } }}
|
slotProps={{ backdrop: { invisible: true } }}
|
||||||
PaperProps={{ sx: { width: 1, maxWidth: 420 } }}
|
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 }}>
|
{/* Footer */}
|
||||||
<Button fullWidth size="large">
|
<Box sx={{ p: 1, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||||
View all
|
<Button
|
||||||
|
fullWidth
|
||||||
|
size="large"
|
||||||
|
onClick={() => { drawer.onFalse(); router.push(paths.dashboard.notifications); }}
|
||||||
|
>
|
||||||
|
Смотреть все
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,8 @@ const ICONS = {
|
||||||
course: icon('ic-course'),
|
course: icon('ic-course'),
|
||||||
calendar: icon('ic-calendar'),
|
calendar: icon('ic-calendar'),
|
||||||
dashboard: icon('ic-dashboard'),
|
dashboard: icon('ic-dashboard'),
|
||||||
|
kanban: icon('ic-kanban'),
|
||||||
|
folder: icon('ic-folder'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
|
|
@ -32,9 +34,12 @@ export const navData = [
|
||||||
{
|
{
|
||||||
subheader: 'Инструменты',
|
subheader: 'Инструменты',
|
||||||
items: [
|
items: [
|
||||||
{ title: 'Ученики', path: paths.dashboard.user.list, icon: ICONS.user },
|
{ title: 'Ученики', path: paths.dashboard.students, icon: ICONS.user },
|
||||||
{ title: 'Schedule', path: paths.dashboard.calendar, icon: ICONS.calendar },
|
{ title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar },
|
||||||
|
{ title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban },
|
||||||
|
{ title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder },
|
||||||
{ title: 'Чат', path: paths.dashboard.chat, icon: ICONS.chat },
|
{ 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: {
|
jwt: {
|
||||||
signIn: `${ROOTS.AUTH}/jwt/sign-in`,
|
signIn: `${ROOTS.AUTH}/jwt/sign-in`,
|
||||||
signUp: `${ROOTS.AUTH}/jwt/sign-up`,
|
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: {
|
firebase: {
|
||||||
signIn: `${ROOTS.AUTH}/firebase/sign-in`,
|
signIn: `${ROOTS.AUTH}/firebase/sign-in`,
|
||||||
|
|
@ -99,6 +102,10 @@ export const paths = {
|
||||||
blank: `${ROOTS.DASHBOARD}/blank`,
|
blank: `${ROOTS.DASHBOARD}/blank`,
|
||||||
kanban: `${ROOTS.DASHBOARD}/kanban`,
|
kanban: `${ROOTS.DASHBOARD}/kanban`,
|
||||||
calendar: `${ROOTS.DASHBOARD}/schedule`,
|
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`,
|
fileManager: `${ROOTS.DASHBOARD}/file-manager`,
|
||||||
permission: `${ROOTS.DASHBOARD}/permission`,
|
permission: `${ROOTS.DASHBOARD}/permission`,
|
||||||
general: {
|
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