From d4ec417ebfc7616e271f53fcb5f0797169042187 Mon Sep 17 00:00:00 2001 From: Dev Server Date: Mon, 9 Mar 2026 10:09:30 +0300 Subject: [PATCH] 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 --- .../src/app/dashboard/homework/page.jsx | 11 + .../src/app/dashboard/materials/page.jsx | 11 + .../src/app/dashboard/notifications/page.jsx | 11 + .../src/app/dashboard/students/page.jsx | 11 + .../components/notifications-drawer/index.jsx | 280 +++++--- .../src/layouts/config-nav-dashboard.jsx | 9 +- front_minimal/src/routes/paths.js | 7 + .../homework/homework-details-drawer.jsx | 605 ++++++++++++++++++ .../homework/homework-edit-drawer.jsx | 254 ++++++++ .../homework/homework-submit-drawer.jsx | 265 ++++++++ .../sections/homework/view/homework-view.jsx | 406 ++++++++++++ .../src/sections/homework/view/index.js | 1 + .../src/sections/materials/view/index.js | 1 + .../materials/view/materials-view.jsx | 411 ++++++++++++ .../src/sections/notifications/view/index.js | 1 + .../notifications/view/notifications-view.jsx | 244 +++++++ .../src/sections/students/view/index.js | 1 + .../sections/students/view/students-view.jsx | 517 +++++++++++++++ front_minimal/src/utils/homework-api.js | 135 ++++ front_minimal/src/utils/materials-api.js | 71 ++ front_minimal/src/utils/notifications-api.js | 29 + front_minimal/src/utils/students-api.js | 67 ++ 22 files changed, 3259 insertions(+), 89 deletions(-) create mode 100644 front_minimal/src/app/dashboard/homework/page.jsx create mode 100644 front_minimal/src/app/dashboard/materials/page.jsx create mode 100644 front_minimal/src/app/dashboard/notifications/page.jsx create mode 100644 front_minimal/src/app/dashboard/students/page.jsx create mode 100644 front_minimal/src/sections/homework/homework-details-drawer.jsx create mode 100644 front_minimal/src/sections/homework/homework-edit-drawer.jsx create mode 100644 front_minimal/src/sections/homework/homework-submit-drawer.jsx create mode 100644 front_minimal/src/sections/homework/view/homework-view.jsx create mode 100644 front_minimal/src/sections/homework/view/index.js create mode 100644 front_minimal/src/sections/materials/view/index.js create mode 100644 front_minimal/src/sections/materials/view/materials-view.jsx create mode 100644 front_minimal/src/sections/notifications/view/index.js create mode 100644 front_minimal/src/sections/notifications/view/notifications-view.jsx create mode 100644 front_minimal/src/sections/students/view/index.js create mode 100644 front_minimal/src/sections/students/view/students-view.jsx create mode 100644 front_minimal/src/utils/homework-api.js create mode 100644 front_minimal/src/utils/materials-api.js create mode 100644 front_minimal/src/utils/notifications-api.js create mode 100644 front_minimal/src/utils/students-api.js diff --git a/front_minimal/src/app/dashboard/homework/page.jsx b/front_minimal/src/app/dashboard/homework/page.jsx new file mode 100644 index 0000000..d65445f --- /dev/null +++ b/front_minimal/src/app/dashboard/homework/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/materials/page.jsx b/front_minimal/src/app/dashboard/materials/page.jsx new file mode 100644 index 0000000..bde7e98 --- /dev/null +++ b/front_minimal/src/app/dashboard/materials/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/notifications/page.jsx b/front_minimal/src/app/dashboard/notifications/page.jsx new file mode 100644 index 0000000..616c1ec --- /dev/null +++ b/front_minimal/src/app/dashboard/notifications/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/app/dashboard/students/page.jsx b/front_minimal/src/app/dashboard/students/page.jsx new file mode 100644 index 0000000..2207113 --- /dev/null +++ b/front_minimal/src/app/dashboard/students/page.jsx @@ -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 ; +} diff --git a/front_minimal/src/layouts/components/notifications-drawer/index.jsx b/front_minimal/src/layouts/components/notifications-drawer/index.jsx index d67efb2..40e2fed 100644 --- a/front_minimal/src/layouts/components/notifications-drawer/index.jsx +++ b/front_minimal/src/layouts/components/notifications-drawer/index.jsx @@ -1,116 +1,122 @@ 'use client'; import { m } from 'framer-motion'; -import { useState, useCallback } from 'react'; +import { useState, useEffect, useCallback } from 'react'; -import Tab from '@mui/material/Tab'; import Box from '@mui/material/Box'; +import Tab from '@mui/material/Tab'; +import List from '@mui/material/List'; import Stack from '@mui/material/Stack'; import Badge from '@mui/material/Badge'; import Drawer from '@mui/material/Drawer'; import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; import SvgIcon from '@mui/material/SvgIcon'; import Tooltip from '@mui/material/Tooltip'; +import ListItem from '@mui/material/ListItem'; import Typography from '@mui/material/Typography'; import IconButton from '@mui/material/IconButton'; +import ListItemText from '@mui/material/ListItemText'; +import CircularProgress from '@mui/material/CircularProgress'; + +import { paths } from 'src/routes/paths'; +import { useRouter } from 'src/routes/hooks'; import { useBoolean } from 'src/hooks/use-boolean'; +import { + markAsRead, + markAllAsRead, + getNotifications, +} from 'src/utils/notifications-api'; + import { Label } from 'src/components/label'; import { Iconify } from 'src/components/iconify'; import { varHover } from 'src/components/animate'; import { Scrollbar } from 'src/components/scrollbar'; import { CustomTabs } from 'src/components/custom-tabs'; -import { NotificationItem } from './notification-item'; +// ---------------------------------------------------------------------- + +function formatDate(s) { + if (!s) return ''; + const d = new Date(s); + return Number.isNaN(d.getTime()) + ? '' + : d.toLocaleString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); +} + +function notifIcon(n) { + const t = n.type || n.notification_type || ''; + if (t === 'success') return 'eva:checkmark-circle-2-outline'; + if (t === 'warning') return 'eva:alert-triangle-outline'; + if (t === 'error') return 'eva:close-circle-outline'; + return 'eva:bell-outline'; +} + +function notifColor(n) { + const t = n.type || n.notification_type || ''; + if (t === 'success') return 'success.main'; + if (t === 'warning') return 'warning.main'; + if (t === 'error') return 'error.main'; + return 'info.main'; +} // ---------------------------------------------------------------------- -const TABS = [ - { value: 'all', label: 'All', count: 22 }, - { value: 'unread', label: 'Unread', count: 12 }, - { value: 'archived', label: 'Archived', count: 10 }, -]; - -// ---------------------------------------------------------------------- - -export function NotificationsDrawer({ data = [], sx, ...other }) { +export function NotificationsDrawer({ sx, ...other }) { const drawer = useBoolean(); + const router = useRouter(); + const [currentTab, setCurrentTab] = useState('unread'); + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(false); - const [currentTab, setCurrentTab] = useState('all'); - - const handleChangeTab = useCallback((event, newValue) => { - setCurrentTab(newValue); + const load = useCallback(async () => { + try { + setLoading(true); + const res = await getNotifications({ page_size: 50 }); + setNotifications(res.results); + } catch { + // silently fail + } finally { + setLoading(false); + } }, []); - const [notifications, setNotifications] = useState(data); + // Load when drawer opens + useEffect(() => { + if (drawer.value) load(); + }, [drawer.value, load]); - const totalUnRead = notifications.filter((item) => item.isUnRead === true).length; + const unreadCount = notifications.filter((n) => !n.is_read).length; - const handleMarkAllAsRead = () => { - setNotifications(notifications.map((notification) => ({ ...notification, isUnRead: false }))); + const filtered = + currentTab === 'unread' + ? notifications.filter((n) => !n.is_read) + : notifications; + + const handleMarkAllAsRead = async () => { + try { + await markAllAsRead(); + setNotifications((prev) => prev.map((n) => ({ ...n, is_read: true }))); + } catch { + // ignore + } }; - const renderHead = ( - - - Notifications - + const handleMarkOne = async (id) => { + try { + await markAsRead(id); + setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, is_read: true } : n)); + } catch { + // ignore + } + }; - {!!totalUnRead && ( - - - - - - )} - - - - - - - - - - ); - - const renderTabs = ( - - {TABS.map((tab) => ( - - {tab.count} - - } - /> - ))} - - ); - - const renderList = ( - - - {notifications?.map((notification) => ( - - - - ))} - - - ); + const TABS = [ + { value: 'unread', label: 'Непрочитанные', count: unreadCount }, + { value: 'all', label: 'Все', count: notifications.length }, + ]; return ( <> @@ -123,9 +129,8 @@ export function NotificationsDrawer({ data = [], sx, ...other }) { sx={sx} {...other} > - + - {/* https://icon-sets.iconify.design/solar/bell-bing-bold-duotone/ */} - {renderHead} + {/* Head */} + + + Уведомления + + {unreadCount > 0 && ( + + + + + + )} + + + + - {renderTabs} + {/* Tabs */} + setCurrentTab(v)}> + {TABS.map((tab) => ( + + {tab.count} + + } + /> + ))} + - {renderList} + {/* List */} + + {loading ? ( + + + + ) : filtered.length === 0 ? ( + + + + {currentTab === 'unread' ? 'Нет непрочитанных' : 'Нет уведомлений'} + + + ) : ( + + {filtered.map((n, idx) => ( + + !n.is_read && handleMarkOne(n.id)} + > + + + + + + {n.title || 'Уведомление'} + + {!n.is_read && ( + + )} + + } + secondary={ + <> + + {n.message} + + + {formatDate(n.created_at)} + + + } + /> + + {idx < filtered.length - 1 && } + + ))} + + )} + - - diff --git a/front_minimal/src/layouts/config-nav-dashboard.jsx b/front_minimal/src/layouts/config-nav-dashboard.jsx index cdd25cb..f4c7c22 100644 --- a/front_minimal/src/layouts/config-nav-dashboard.jsx +++ b/front_minimal/src/layouts/config-nav-dashboard.jsx @@ -12,6 +12,8 @@ const ICONS = { course: icon('ic-course'), calendar: icon('ic-calendar'), dashboard: icon('ic-dashboard'), + kanban: icon('ic-kanban'), + folder: icon('ic-folder'), }; // ---------------------------------------------------------------------- @@ -32,9 +34,12 @@ export const navData = [ { subheader: 'Инструменты', items: [ - { title: 'Ученики', path: paths.dashboard.user.list, icon: ICONS.user }, - { title: 'Schedule', path: paths.dashboard.calendar, icon: ICONS.calendar }, + { title: 'Ученики', path: paths.dashboard.students, icon: ICONS.user }, + { title: 'Расписание', path: paths.dashboard.calendar, icon: ICONS.calendar }, + { title: 'Домашние задания', path: paths.dashboard.homework, icon: ICONS.kanban }, + { title: 'Материалы', path: paths.dashboard.materials, icon: ICONS.folder }, { title: 'Чат', path: paths.dashboard.chat, icon: ICONS.chat }, + { title: 'Уведомления', path: paths.dashboard.notifications, icon: icon('ic-label') }, ], }, ]; diff --git a/front_minimal/src/routes/paths.js b/front_minimal/src/routes/paths.js index 6989d0b..0e8372f 100644 --- a/front_minimal/src/routes/paths.js +++ b/front_minimal/src/routes/paths.js @@ -57,6 +57,9 @@ export const paths = { jwt: { signIn: `${ROOTS.AUTH}/jwt/sign-in`, signUp: `${ROOTS.AUTH}/jwt/sign-up`, + forgotPassword: `${ROOTS.AUTH}/jwt/forgot-password`, + resetPassword: `${ROOTS.AUTH}/jwt/reset-password`, + verifyEmail: `${ROOTS.AUTH}/jwt/verify-email`, }, firebase: { signIn: `${ROOTS.AUTH}/firebase/sign-in`, @@ -99,6 +102,10 @@ export const paths = { blank: `${ROOTS.DASHBOARD}/blank`, kanban: `${ROOTS.DASHBOARD}/kanban`, calendar: `${ROOTS.DASHBOARD}/schedule`, + homework: `${ROOTS.DASHBOARD}/homework`, + materials: `${ROOTS.DASHBOARD}/materials`, + students: `${ROOTS.DASHBOARD}/students`, + notifications: `${ROOTS.DASHBOARD}/notifications`, fileManager: `${ROOTS.DASHBOARD}/file-manager`, permission: `${ROOTS.DASHBOARD}/permission`, general: { diff --git a/front_minimal/src/sections/homework/homework-details-drawer.jsx b/front_minimal/src/sections/homework/homework-details-drawer.jsx new file mode 100644 index 0000000..0566e85 --- /dev/null +++ b/front_minimal/src/sections/homework/homework-details-drawer.jsx @@ -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 ( + + {items.map(({ label: l, url }, i) => ( + + ))} + + ); +} + +// ---------------------------------------------------------------------- +// 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 ; + + if (!submission) { + return ( + + + Вы ещё не сдали задание. + + {homework.status === 'published' && !childId && ( + + )} + + ); + } + + return ( + + + + + {formatDateTime(submission.submitted_at)} + + + + {submission.content && ( + + + {submission.content} + + + )} + + f.file_type === 'submission')} + label="Решение" + /> + + {submission.score != null && ( + + Оценка + + + {submission.score} / {homework.max_score || 5} + + + )} + + {submission.feedback && ( + + + Комментарий ментора + + {submission.feedback_html ? ( + + ) : ( + + + {submission.feedback} + + + )} + + )} + + {error && {error}} + + {submission.status !== 'checked' && !childId && ( + + + + )} + + ); +} + +// ---------------------------------------------------------------------- +// 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 ( + + + + {student?.first_name} {student?.last_name} + + + + + {submission.content && ( + + + {submission.content} + + + )} + + f.file_type === 'submission')} + label="Решение" + /> + + {/* AI draft info */} + {submission.ai_score != null && !isChecked && ( + + + Черновик от ИИ: оценка {submission.ai_score}/5 + + {submission.ai_feedback && ( + + {submission.ai_feedback} + + )} + + )} + + {error && ( + + {error} + + )} + + {/* Grade form */} + {!isChecked && !showReturnForm && ( + + + + Оценка: + + setScore(v ?? '')} + /> + + setFeedback(e.target.value)} + size="small" + fullWidth + /> + + + + + + + )} + + {/* Return form */} + {showReturnForm && ( + + setReturnFeedback(e.target.value)} + size="small" + fullWidth + /> + + + + + + )} + + ); +} + +// ---------------------------------------------------------------------- +// 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 ( + + {/* Header */} + + + {homework.title} + {homework.deadline && ( + + Дедлайн: {formatDateTime(homework.deadline)} + {homework.is_overdue && ' • Просрочено'} + + )} + + + + + + + {/* Tabs for mentor */} + {isMentor && ( + setTab(v)} sx={{ px: 3, borderBottom: '1px solid', borderColor: 'divider' }}> + + + + )} + + + {/* Assignment tab / single view */} + {(!isMentor || tab === 0) && ( + + {homework.description && ( + + + Описание + + + {homework.description} + + + )} + + {(homework.attachment || assignmentFiles.length > 0 || homework.attachment_url) && ( + + + Файлы задания + + + {homework.attachment_url && ( + + )} + + )} + + {isMentor && homework.fill_later && ( + { onClose(); onOpenEdit(homework); }}> + Заполнить + + } + > + Задание ожидает заполнения + + )} + + {isMentor && ( + + + Статус: {homework.status === 'published' ? 'Опубликовано' : homework.status === 'draft' ? 'Черновик' : 'Архив'} + + {homework.average_score > 0 && ( + + Средняя оценка: {homework.average_score.toFixed(1)} / 5 + + )} + + )} + + {/* Client submission section */} + {(userRole === 'client' || userRole === 'parent') && homework.status === 'published' && ( + <> + + + + Ваше решение + + + + + )} + + )} + + {/* Submissions tab for mentor */} + {isMentor && tab === 1 && ( + + {subsLoading ? ( + + + + ) : submissions.length === 0 ? ( + + Нет решений + + ) : ( + submissions.map((sub) => ( + { + loadSubmissions(); + onSuccess(); + }} + /> + )) + )} + + )} + + + ); +} diff --git a/front_minimal/src/sections/homework/homework-edit-drawer.jsx b/front_minimal/src/sections/homework/homework-edit-drawer.jsx new file mode 100644 index 0000000..1b3d081 --- /dev/null +++ b/front_minimal/src/sections/homework/homework-edit-drawer.jsx @@ -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 ( + + + Заполнить домашнее задание + + + + + + + setTitle(e.target.value)} + disabled={isLoading} + fullWidth + /> + + setDescription(e.target.value)} + disabled={isLoading} + placeholder="Опишите задание, шаги, ссылки..." + fullWidth + /> + + + + + + Файлы к заданию + + + + + + {existingFiles.length > 0 && ( + + {existingFiles.map((file) => ( + handleRemoveFile(file.id)} + icon={} + /> + ))} + + )} + + + {error && {error}} + + + + + + + + ); +} diff --git a/front_minimal/src/sections/homework/homework-submit-drawer.jsx b/front_minimal/src/sections/homework/homework-submit-drawer.jsx new file mode 100644 index 0000000..3286a66 --- /dev/null +++ b/front_minimal/src/sections/homework/homework-submit-drawer.jsx @@ -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 ( + + + Отправить решение + + + + + + + {assignmentFiles.length > 0 && ( + + + Файлы задания (от ментора) + + + {assignmentFiles.map(({ label, url }, i) => ( + + ))} + + + )} + + setContent(e.target.value)} + disabled={loading} + placeholder="Введите текст решения..." + fullWidth + /> + + + + Файлы (до 50 МБ, не более 10 шт.) + + + + + + {files.length > 0 && ( + + {files.map((f, i) => ( + + + {f.name} + + setFiles((p) => p.filter((_, j) => j !== i))} + disabled={loading} + color="error" + > + + + + ))} + + )} + + {uploadProgress != null && ( + + + + Загрузка на сервер + + + {uploadProgress}% + + + + + )} + + + {error && {error}} + + + + + + + + ); +} diff --git a/front_minimal/src/sections/homework/view/homework-view.jsx b/front_minimal/src/sections/homework/view/homework-view.jsx new file mode 100644 index 0000000..1a522a5 --- /dev/null +++ b/front_minimal/src/sections/homework/view/homework-view.jsx @@ -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 ( + + + + + + {hw.title} + + + + + {userRole === 'client' && ( + + + + {hw.mentor?.first_name} {hw.mentor?.last_name} + + + )} + + {userRole === 'mentor' && hw.total_submissions > 0 && ( + + Решений: {hw.total_submissions} + {hw.checked_submissions > 0 && ` • Проверено: ${hw.checked_submissions}`} + + )} + + {hw.deadline && ( + + + + {formatDate(hw.deadline)} {formatTime(hw.deadline)} + + + )} + + {hw.student_score?.score != null && ( + + Оценка: {hw.student_score.score} / {hw.max_score || 5} + + )} + + {hw.ai_draft_count > 0 && userRole === 'mentor' && ( + + )} + + + + {((userRole === 'client' && status === 'pending' && !hw.is_overdue && hw.status === 'published') || + (userRole === 'mentor' && isFillLater)) && ( + + {userRole === 'client' && ( + + )} + {userRole === 'mentor' && isFillLater && ( + + )} + + )} + + ); +} + +// ---------------------------------------------------------------------- + +function HomeworkColumn({ title, items, userRole, onView, onSubmit, onEdit, emptyText }) { + return ( + + + {title} + {items.length > 0 && ( + + ({items.length}) + + )} + + + {items.length === 0 ? ( + + {emptyText || 'Нет заданий'} + + ) : ( + items.map((hw) => ( + onView(hw)} + onSubmit={() => onSubmit(hw)} + onEdit={() => onEdit(hw)} + /> + )) + )} + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + + + {error && ( + + {error} + + )} + + {loading ? ( + + + + ) : homework.length === 0 ? ( + + Нет домашних заданий + + ) : ( + + {userRole === 'mentor' && fillLater.length > 0 && ( + + )} + + + + + + {userRole === 'mentor' && aiDraft.length > 0 && ( + + )} + + + + + + )} + + {/* Details Drawer */} + { setDetailsOpen(false); setSelectedHw(null); }} + onSuccess={loadHomework} + onOpenSubmit={() => { + setSubmitHwId(selectedHw?.id); + setSubmitOpen(true); + }} + onOpenEdit={(hw) => { + setEditHw(hw); + setEditOpen(true); + }} + /> + + {/* Submit Drawer */} + { setSubmitOpen(false); setSubmitHwId(null); }} + onSuccess={loadHomework} + /> + + {/* Edit Draft Drawer */} + { setEditOpen(false); setEditHw(null); }} + onSuccess={loadHomework} + /> + + ); +} diff --git a/front_minimal/src/sections/homework/view/index.js b/front_minimal/src/sections/homework/view/index.js new file mode 100644 index 0000000..e363f6b --- /dev/null +++ b/front_minimal/src/sections/homework/view/index.js @@ -0,0 +1 @@ +export * from './homework-view'; diff --git a/front_minimal/src/sections/materials/view/index.js b/front_minimal/src/sections/materials/view/index.js new file mode 100644 index 0000000..501cefa --- /dev/null +++ b/front_minimal/src/sections/materials/view/index.js @@ -0,0 +1 @@ +export * from './materials-view'; diff --git a/front_minimal/src/sections/materials/view/materials-view.jsx b/front_minimal/src/sections/materials/view/materials-view.jsx new file mode 100644 index 0000000..b8fea10 --- /dev/null +++ b/front_minimal/src/sections/materials/view/materials-view.jsx @@ -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 ( + + {isImage && url ? ( + + + + ) : ( + + + + )} + + + + {material.title} + + {material.description && ( + + {material.description} + + )} + {(material.file_size || material.category_name) && ( + + {material.category_name && ( + + {material.category_name} + + )} + {material.file_size && ( + + {formatSize(material.file_size)} + + )} + + )} + + + + {url && ( + + + + + + )} + {isMentor && ( + + onDelete(material)}> + + + + )} + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + Добавить материал + + + setTitle(e.target.value)} + disabled={loading} + fullWidth + /> + setDescription(e.target.value)} + disabled={loading} + multiline + rows={2} + fullWidth + /> + + setFile(e.target.files?.[0] || null)} + disabled={loading} + style={{ display: 'none' }} + /> + + + {error && {error}} + + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + } + onClick={() => setUploadOpen(true)} + > + Добавить + + ) + } + sx={{ mb: 3 }} + /> + + + setSearch(e.target.value)} + size="small" + sx={{ width: 300 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + + {error && ( + + {error} + + )} + + {loading ? ( + + + + ) : filtered.length === 0 ? ( + + {search ? 'Ничего не найдено' : 'Нет материалов'} + + ) : ( + + {filtered.map((material) => ( + + + + ))} + + )} + + {/* Upload */} + setUploadOpen(false)} onSuccess={load} /> + + {/* Delete confirm */} + !deleting && setDeleteTarget(null)} maxWidth="xs" fullWidth> + Удалить материал? + + + «{deleteTarget?.title}» будет удалён безвозвратно. + + + + + + + + + ); +} diff --git a/front_minimal/src/sections/notifications/view/index.js b/front_minimal/src/sections/notifications/view/index.js new file mode 100644 index 0000000..768a92b --- /dev/null +++ b/front_minimal/src/sections/notifications/view/index.js @@ -0,0 +1 @@ +export * from './notifications-view'; diff --git a/front_minimal/src/sections/notifications/view/notifications-view.jsx b/front_minimal/src/sections/notifications/view/notifications-view.jsx new file mode 100644 index 0000000..4870e3e --- /dev/null +++ b/front_minimal/src/sections/notifications/view/notifications-view.jsx @@ -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 ( + + 0 && ( + + ) + } + sx={{ mb: 3 }} + /> + + {error && {error}} + + setTab(v)} sx={{ mb: 3 }}> + + 0 ? 1.5 : 0 }}>Непрочитанные + + } /> + + + + {loading ? ( + + + + ) : filtered.length === 0 ? ( + + + + {tab === 'unread' ? 'Нет непрочитанных уведомлений' : 'Нет уведомлений'} + + + ) : ( + + {filtered.map((n, idx) => ( + + + {!n.is_read && ( + + handleMarkAsRead(n.id)} + disabled={processing === n.id} + > + + + + )} + + handleDelete(n.id)} + disabled={processing === n.id} + > + + + + + } + > + + + + + + {n.title || 'Уведомление'} + + {!n.is_read && ( + + )} + + } + secondary={ + <> + + {n.message} + + + {formatDate(n.created_at)} + + + } + /> + + {idx < filtered.length - 1 && } + + ))} + + )} + + ); +} diff --git a/front_minimal/src/sections/students/view/index.js b/front_minimal/src/sections/students/view/index.js new file mode 100644 index 0000000..6496d7c --- /dev/null +++ b/front_minimal/src/sections/students/view/index.js @@ -0,0 +1 @@ +export * from './students-view'; diff --git a/front_minimal/src/sections/students/view/students-view.jsx b/front_minimal/src/sections/students/view/students-view.jsx new file mode 100644 index 0000000..9a11205 --- /dev/null +++ b/front_minimal/src/sections/students/view/students-view.jsx @@ -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 ; + + return ( + + setSearch(e.target.value)} + size="small" + InputProps={{ + startAdornment: , + }} + /> + + {error && {error}} + + {filtered.length === 0 ? ( + + {search ? 'Ничего не найдено' : 'Нет учеников'} + + ) : ( + + {filtered.map((s) => { + const u = s.user || {}; + return ( + + + + {initials(u.first_name, u.last_name)} + + + + {s.total_lessons != null && ( + + {s.total_lessons} уроков + + )} + + ); + })} + + )} + + ); +} + +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 ; + + return ( + + {error && {error}} + {requests.length === 0 ? ( + Нет входящих заявок + ) : ( + + {requests.map((r) => { + const s = r.student || {}; + return ( + + + + + } + > + + + {initials(s.first_name, s.last_name)} + + + + + ); + })} + + )} + + ); +} + +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 ( + { if (!loading) { reset(); onClose(); } }} maxWidth="xs" fullWidth> + Пригласить ученика + + + setMode(v)} size="small"> + + + + setValue(e.target.value)} + disabled={loading} + fullWidth + size="small" + /> + {error && {error}} + {successMsg && {successMsg}} + + + + + + + + ); +} + +// ---------------------------------------------------------------------- +// 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 ; + + return ( + + Мои менторы + {mentors.length === 0 ? ( + Нет подключённых менторов + ) : ( + + {mentors.map((m) => ( + + + + {initials(m.first_name, m.last_name)} + + + + + ))} + + )} + + ); +} + +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 ( + + + + + {error && {error}} + + {invitations.map((inv) => { + const m = inv.mentor || {}; + return ( + + + + + } + > + + {initials(m.first_name, m.last_name)} + + + + ); + })} + + + + ); +} + +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 ( + { if (!loading) { setCode(''); setError(null); setSuccessMsg(null); onClose(); } }} maxWidth="xs" fullWidth> + Найти ментора + + + setCode(e.target.value.toUpperCase())} + disabled={loading} + fullWidth + size="small" + placeholder="XXXXXXXX" + /> + {error && {error}} + {successMsg && {successMsg}} + + + + + + + + ); +} + +// ---------------------------------------------------------------------- + +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 ( + + } onClick={() => setInviteOpen(true)}> + Пригласить ученика + + ) : ( + + ) + } + sx={{ mb: 3 }} + /> + + {isMentor ? ( + <> + setTab(v)} sx={{ mb: 3 }}> + + + + {tab === 0 && } + {tab === 1 && } + setInviteOpen(false)} onSuccess={refresh} /> + + ) : ( + <> + + + setRequestOpen(false)} onSuccess={refresh} /> + + )} + + ); +} diff --git a/front_minimal/src/utils/homework-api.js b/front_minimal/src/utils/homework-api.js new file mode 100644 index 0000000..68c2299 --- /dev/null +++ b/front_minimal/src/utils/homework-api.js @@ -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'; +} diff --git a/front_minimal/src/utils/materials-api.js b/front_minimal/src/utils/materials-api.js new file mode 100644 index 0000000..008dbe7 --- /dev/null +++ b/front_minimal/src/utils/materials-api.js @@ -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'; +} diff --git a/front_minimal/src/utils/notifications-api.js b/front_minimal/src/utils/notifications-api.js new file mode 100644 index 0000000..2e8f47f --- /dev/null +++ b/front_minimal/src/utils/notifications-api.js @@ -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}/`); +} diff --git a/front_minimal/src/utils/students-api.js b/front_minimal/src/utils/students-api.js new file mode 100644 index 0000000..89277dd --- /dev/null +++ b/front_minimal/src/utils/students-api.js @@ -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; +}