добавил журнал успеваемости

This commit is contained in:
Андрей Дувакин 2025-11-30 11:16:03 +05:00
parent f4e3cbf3be
commit 8f44b12021
7 changed files with 399 additions and 704 deletions

View File

@ -7,11 +7,13 @@ from app.database.session import get_db
from app.domain.entities.course_teachers import CourseTeacherRead, CourseTeacherCreate
from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated
from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate
from app.domain.entities.gradebook import GradeBookRead
from app.domain.models import User
from app.infrastructure.course_teachers_service import CourseTeachersService
from app.infrastructure.courses_service import CoursesService
from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin
from app.infrastructure.enrollments_service import EnrollmentsService
from app.infrastructure.gradebook_service import GradeBookService
courses_router = APIRouter()
@ -152,3 +154,13 @@ async def replace_course_students(
):
service = EnrollmentsService(db)
return await service.replace_course_students_list(students, course_id)
@courses_router.get("/gradebook/{course_id}/", response_model=GradeBookRead)
async def get_gradebook(
course_id: int,
db: AsyncSession = Depends(get_db),
user: User = Depends(require_auth_user)
):
service = GradeBookService(db)
return await service.get_gradebook_by_course_id(course_id)

View File

@ -0,0 +1,44 @@
from typing import List, Optional, Dict
from pydantic import BaseModel
class LessonInfo(BaseModel):
id: int
title: str
number: int
class Config:
from_attributes = True
class TaskInfo(BaseModel):
id: int
title: str
number: int
max_points: int = 100
class Config:
from_attributes = True
class StudentProgress(BaseModel):
student_id: int
first_name: str
last_name: str
patronymic: str = ""
read_lesson_ids: List[int] = []
task_grades: Dict[int, Optional[int]]
class Config:
from_attributes = True
class GradeBookRead(BaseModel):
course_id: int
course_title: str
lessons: List[LessonInfo]
tasks: List[TaskInfo]
students: List[StudentProgress]
class Config:
from_attributes = True

View File

@ -0,0 +1,97 @@
from typing import Dict
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.domain.entities.gradebook import GradeBookRead, StudentProgress, LessonInfo, TaskInfo
from app.domain.models import Course, Lesson, Task, User, Enrollment, UserCheckLessons, Solution
class GradeBookService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_gradebook_by_course_id(self, course_id: int) -> GradeBookRead:
# 1. Курс
course = await self.db.scalar(select(Course).filter_by(id=course_id))
if not course:
raise ValueError("Курс не найден")
# 2. Лекции
lessons = (await self.db.execute(
select(Lesson).filter_by(course_id=course_id).order_by(Lesson.number)
)).scalars().all()
# 3. Задания
tasks = (await self.db.execute(
select(Task).filter_by(course_id=course_id).order_by(Task.number)
)).scalars().all()
# 4. Студенты
students = (await self.db.execute(
select(User)
.join(Enrollment, User.id == Enrollment.student_id)
.filter(Enrollment.course_id == course_id)
.order_by(User.last_name, User.first_name)
)).scalars().all()
student_progress_map: dict[int, StudentProgress] = {}
if students:
student_ids = [s.id for s in students]
# --- Прочитанные лекции ---
read_result = await self.db.execute(
select(UserCheckLessons.user_id, UserCheckLessons.lesson_id)
.filter(UserCheckLessons.user_id.in_(student_ids))
)
read_map: dict[int, set[int]] = {}
for user_id, lesson_id in read_result:
read_map.setdefault(user_id, set()).add(lesson_id)
# --- МАКСИМАЛЬНЫЕ ОЦЕНКИ (ТОЛЬКО ИЗ ОЦЕНЕННЫХ РЕШЕНИЙ!) ---
grades_result = await self.db.execute(
select(
Solution.student_id,
Solution.task_id,
func.max(Solution.assessment)
)
.where(
Solution.student_id.in_(student_ids),
Solution.assessment.is_not(None) # ← Вот это главное!
)
.group_by(Solution.student_id, Solution.task_id)
)
grade_map: dict[tuple[int, int], int] = {
(student_id, task_id): best_grade
for student_id, task_id, best_grade in grades_result
}
# --- Собираем прогресс ---
for student in students:
student_progress_map[student.id] = StudentProgress(
student_id=student.id,
first_name=student.first_name,
last_name=student.last_name,
patronymic=student.patronymic or "",
read_lesson_ids=sorted(read_map.get(student.id, set())),
task_grades={
task.id: grade_map.get((student.id, task.id))
for task in tasks
}
)
return GradeBookRead(
course_id=course_id,
course_title=course.title,
lessons=[
LessonInfo(id=l.id, title=l.title, number=l.number)
for l in lessons
],
tasks=[
TaskInfo(id=t.id, title=t.title, number=t.number)
for t in tasks
],
students=list(student_progress_map.values())
)

View File

@ -74,6 +74,13 @@ export const coursesApi = createApi({
}),
invalidatesTags: ['student'],
}),
getGradebookByCourse: builder.query({
query: (courseId) => ({
url: `/courses/gradebook/${courseId}/`,
method: "GET",
}),
providesTags: ['gradebook'],
}),
}),
});
@ -87,4 +94,5 @@ export const {
useReplaceCourseTeachersMutation,
useGetCourseStudentsQuery,
useReplaceCourseStudentsMutation,
useGetGradebookByCourseQuery,
} = coursesApi;

View File

@ -214,7 +214,7 @@ const useViewTaskModal = () => {
}
const response = await fetch(`${CONFIG.BASE_URL}/solutions/file/${fileId}/`, {
const response = await fetch(`${CONFIG.BASE_URL}/tasks/file/${fileId}/`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,

View File

@ -5,7 +5,7 @@ import {
Card,
Col,
Empty,
FloatButton,
FloatButton, Grid,
Popconfirm,
Result,
Row,
@ -19,8 +19,8 @@ import {
BookOutlined, CheckCircleFilled, ClockCircleOutlined,
DeleteOutlined,
EditOutlined,
FormOutlined,
PlusOutlined
FormOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
PlusOutlined, TableOutlined
} from "@ant-design/icons";
import {useNavigate, useParams} from "react-router-dom";
import {ROLES} from "../../../Core/constants.js";
@ -32,12 +32,17 @@ import UpdateLessonModalForm from "./Components/UpdateLessonModalForm/UpdateLess
import CreateTaskModalForm from "./Components/CreateTaskModalForm/CreateTaskModalForm.jsx";
import UpdateTaskModalForm from "./Components/UpdateTaskModalForm/UpdateTaskModalForm.jsx";
import ViewTaskModal from "./Components/ViewTaskModalForm/ViewTaskModal.jsx";
import {useState} from "react";
const {Title, Text} = Typography;
const {useBreakpoint} = Grid;
const CourseDetailPage = () => {
const navigate = useNavigate();
const screens = useBreakpoint();
const [hovered, setHovered] = useState(false);
const {courseId} = useParams();
const {
tasksData,
@ -242,6 +247,39 @@ const CourseDetailPage = () => {
<ViewTaskModal
courseId={courseId}
/>
<div
style={{
position: "fixed",
right: 0,
top: "50%",
transform: "translateY(-50%)",
transition: "right 0.3s ease",
zIndex: 1000,
display: screens.xs ? "none" : "block",
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
<Button
type="primary"
onClick={() => {
navigate(`/courses/${courseId}/gradebook/`)
}}
icon={<TableOutlined/>}
style={{
width: hovered ? 250 : 50,
padding: hovered ? "0 20px" : "0",
overflow: "hidden",
textAlign: "left",
transition: "width 0.3s ease, padding 0.3s ease",
borderRadius: "4px 0 0 4px",
}}
>
{hovered && (
<>Журнал успеваемости</>
)}
</Button>
</div>
<UpdateTaskModalForm/>
{[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && (
<FloatButton.Group
@ -262,7 +300,9 @@ const CourseDetailPage = () => {
onClick={handleCreateTask}
/>
</FloatButton.Group>
)}
)
}
</div>
)
};

View File

@ -1,721 +1,215 @@
import { useState, useMemo } from "react";
import { Avatar, Progress, Tag, Tooltip, Space, Button, Table, Card, Row, Col, Statistic, Select, Typography, Input, Modal, Badge } from "antd";
import { UserOutlined, SearchOutlined, SortAscendingOutlined, SortDescendingOutlined, FilterOutlined } from "@ant-design/icons";
import {useParams, useNavigate} from "react-router-dom";
import {
Table,
Card,
Typography,
Spin,
Button,
Space,
Tag,
Tooltip,
Empty,
Result,
Avatar, Statistic,
} from "antd";
import {
ArrowLeftOutlined,
CheckCircleFilled,
ClockCircleOutlined,
MinusOutlined,
UserOutlined,
} from "@ant-design/icons";
import {
useGetCourseByIdQuery,
useGetGradebookByCourseQuery,
} from "../../../Api/coursesApi.js";
import {useGetAuthenticatedUserDataQuery} from "../../../Api/usersApi.js";
const { Title } = Typography;
const { Search } = Input;
const {Title, Text} = Typography;
const GradebookPage = ({ onLogout }) => {
const [selectedCourse, setSelectedCourse] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [filters, setFilters] = useState({
groups: [],
searchText: "",
progressRange: [0, 100]
});
const [sortConfig, setSortConfig] = useState({
key: null,
direction: 'ascend'
});
const [isSearchModalVisible, setIsSearchModalVisible] = useState(false);
const GradebookPage = () => {
const navigate = useNavigate();
const {courseId} = useParams();
// Заглушки для данных
const courses = [
{ id: 1, title: "Основы программирования" },
{ id: 2, title: "Веб-разработка" },
{ id: 3, title: "Базы данных" },
{ id: 4, title: "Алгоритмы и структуры данных" },
];
// Данные текущего пользователя
const {data: userData, isLoading: userLoading} = useGetAuthenticatedUserDataQuery();
const assignments = [
{ id: 1, name: "ДЗ №1", maxScore: 100 },
{ id: 2, name: "ДЗ №2", maxScore: 100 },
{ id: 3, name: "ДЗ №3", maxScore: 100 },
{ id: 4, name: "Проект", maxScore: 200 },
];
const {
data: courseData,
isLoading: courseLoading,
} = useGetCourseByIdQuery(courseId);
const students = [
{
id: 1,
firstName: "Иван",
lastName: "Иванов",
email: "ivanov@mail.com",
group: "ПИ-201",
grades: { 1: 85, 2: 92, 3: 78, 4: 180 },
progress: 85
},
{
id: 2,
firstName: "Мария",
lastName: "Петрова",
email: "petrova@mail.com",
group: "ПИ-201",
grades: { 1: 95, 2: 88, 3: 91, 4: 190 },
progress: 92
},
{
id: 3,
firstName: "Алексей",
lastName: "Сидоров",
email: "sidorov@mail.com",
group: "ПИ-202",
grades: { 1: 72, 2: null, 3: 85, 4: null },
progress: 52
},
{
id: 4,
firstName: "Екатерина",
lastName: "Смирнова",
email: "smirnova@mail.com",
group: "ПИ-202",
grades: { 1: 88, 2: 90, 3: 87, 4: 175 },
progress: 88
},
{
id: 5,
firstName: "Дмитрий",
lastName: "Козлов",
email: "kozlov@mail.com",
group: "ПИ-203",
grades: { 1: 65, 2: 70, 3: null, 4: null },
progress: 45
},
{
id: 6,
firstName: "Анна",
lastName: "Волкова",
email: "volkova@mail.com",
group: "ПИ-201",
grades: { 1: 100, 2: 98, 3: 95, 4: 195 },
progress: 97
},
{
id: 7,
firstName: "Сергей",
lastName: "Орлов",
email: "orlov@mail.com",
group: "ПИ-203",
grades: { 1: 80, 2: 85, 3: 82, 4: 170 },
progress: 79
},
{
id: 8,
firstName: "Ольга",
lastName: "Лебедева",
email: "lebedeva@mail.com",
group: "ПИ-202",
grades: { 1: 90, 2: 92, 3: 88, 4: 185 },
progress: 89
},
];
const {
data: gradebook,
isLoading: gradebookLoading,
isError: gradebookError,
} = useGetGradebookByCourseQuery(courseId, {pollingInterval: 15000});
// Получение уникальных групп для фильтра
const uniqueGroups = useMemo(() => {
return [...new Set(students.map(student => student.group))];
}, [students]);
const isLoading = courseLoading || gradebookLoading || userLoading;
// Фильтрация и сортировка данных
const filteredAndSortedStudents = useMemo(() => {
let filtered = students.filter(student => {
// Фильтр по группам
if (filters.groups.length > 0 && !filters.groups.includes(student.group)) {
return false;
if (isLoading) {
return (
<div style={{padding: 80, textAlign: "center"}}>
<Spin size="large" tip="Загружается журнал..."/>
</div>
);
}
// Поиск по тексту (ФИО, email, группа)
if (filters.searchText) {
const searchLower = filters.searchText.toLowerCase();
const fullName = `${student.firstName} ${student.lastName}`.toLowerCase();
if (!fullName.includes(searchLower) &&
!student.email.toLowerCase().includes(searchLower) &&
!student.group.toLowerCase().includes(searchLower)) {
return false;
}
if (gradebookError || !gradebook) {
return <Empty description="Не удалось загрузить журнал"/>;
}
// Фильтр по прогрессу
if (student.progress < filters.progressRange[0] || student.progress > filters.progressRange[1]) {
return false;
const isStudent = userData?.role?.title === "student";
const currentStudentId = userData?.id;
// Если студент фильтруем только его данные
const visibleStudents = isStudent
? gradebook.students.filter((s) => s.student_id === currentStudentId)
: gradebook.students;
// Если студент, но его нет в курсе покажем сообщение
if (isStudent && visibleStudents.length === 0) {
return (
<Card style={{margin: 24}}>
<Result
status="info"
title="Вы не записаны на этот курс"
subTitle="Чтобы видеть свою успеваемость, нужно быть зачисленным на курс."
extra={
<Button type="primary" onClick={() => navigate(-1)}>
Назад
</Button>
}
/>
</Card>
);
}
return true;
});
// Сортировка
if (sortConfig.key) {
filtered.sort((a, b) => {
let aValue, bValue;
switch (sortConfig.key) {
case 'student':
aValue = `${a.firstName} ${a.lastName}`;
bValue = `${b.firstName} ${b.lastName}`;
break;
case 'group':
aValue = a.group;
bValue = b.group;
break;
case 'progress':
aValue = a.progress;
bValue = b.progress;
break;
case 'average':
const aGrades = Object.values(a.grades).filter(g => g !== null);
const bGrades = Object.values(b.grades).filter(g => g !== null);
aValue = aGrades.length > 0 ? aGrades.reduce((sum, grade) => sum + grade, 0) / aGrades.length : 0;
bValue = bGrades.length > 0 ? bGrades.reduce((sum, grade) => sum + grade, 0) / bGrades.length : 0;
break;
default:
// Сортировка по заданиям
if (sortConfig.key.startsWith('assignment_')) {
const assignmentId = parseInt(sortConfig.key.split('_')[1]);
aValue = a.grades[assignmentId] || 0;
bValue = b.grades[assignmentId] || 0;
} else {
aValue = a[sortConfig.key];
bValue = b[sortConfig.key];
}
}
if (aValue < bValue) {
return sortConfig.direction === 'ascend' ? -1 : 1;
}
if (aValue > bValue) {
return sortConfig.direction === 'ascend' ? 1 : -1;
}
return 0;
});
}
return filtered;
}, [students, filters, sortConfig]);
// Обработчики фильтров
const handleGroupFilter = (selectedGroups) => {
setFilters(prev => ({ ...prev, groups: selectedGroups }));
};
const handleSearch = (value) => {
setFilters(prev => ({ ...prev, searchText: value }));
setIsSearchModalVisible(false);
};
const handleSort = (key) => {
setSortConfig(prev => ({
key,
direction: prev.key === key && prev.direction === 'ascend' ? 'descend' : 'ascend'
}));
};
// Колонки таблицы с фильтрацией и сортировкой
const columns = useMemo(() => {
// === Колонки ===
const baseColumns = [
{
title: (
<Space>
Студент
<Button
type="text"
size="small"
icon={sortConfig.key === 'student' ?
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
<SortAscendingOutlined />
}
onClick={() => handleSort('student')}
/>
<Tooltip title="Поиск студентов">
<Button
type="text"
size="small"
icon={<SearchOutlined />}
onClick={() => setIsSearchModalVisible(true)}
style={{
color: filters.searchText ? '#1890ff' : undefined
}}
/>
</Tooltip>
</Space>
),
key: "student",
title: "№",
width: 60,
fixed: "left",
width: 250,
render: (_, record) => (
<Space>
<Avatar
style={{ backgroundColor: "#1890ff" }}
icon={<UserOutlined />}
>
{record.firstName[0]}{record.lastName[0]}
</Avatar>
<div>
<div style={{ fontWeight: 500 }}>
{record.firstName} {record.lastName}
</div>
<div style={{ fontSize: 12, color: "#8c8c8c" }}>
{record.email}
</div>
</div>
</Space>
),
render: (_, __, index) => index + 1,
},
{
title: (
title: "Студент",
fixed: "left",
width: isStudent ? 300 : 240,
render: (record) => {
const fullName = `${record.last_name} ${record.first_name} ${
record.patronymic ? record.patronymic[0] + "." : ""
}`;
if (isStudent) {
return (
<Space>
Группа
<Button
type="text"
size="small"
icon={sortConfig.key === 'group' ?
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
<SortAscendingOutlined />
}
onClick={() => handleSort('group')}
/>
<Avatar icon={<UserOutlined/>} style={{backgroundColor: "#1890ff"}}/>
<div>
<Text strong>{fullName}</Text>
<br/>
<Text type="secondary">Ваша успеваемость</Text>
</div>
</Space>
),
dataIndex: "group",
key: "group",
width: 120,
render: (group) => <Tag color="blue">{group}</Tag>,
filters: uniqueGroups.map(group => ({ text: group, value: group })),
filteredValue: filters.groups,
onFilter: (value, record) => record.group === value,
);
}
return <Text strong>{fullName}</Text>;
},
},
];
// Колонки для заданий
const assignmentColumns = assignments.map(assignment => ({
const lessonColumns = gradebook.lessons.map((lesson) => ({
title: (
<Space>
{assignment.name}
<Button
type="text"
size="small"
icon={sortConfig.key === `assignment_${assignment.id}` ?
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
<SortAscendingOutlined />
}
onClick={() => handleSort(`assignment_${assignment.id}`)}
/>
</Space>
),
key: `assignment_${assignment.id}`,
width: 130,
align: "center",
render: (_, record) => {
const grade = record.grades[assignment.id];
if (grade === null || grade === undefined) {
return <Tag color="default">Не сдано</Tag>;
}
const percentage = (grade / assignment.maxScore) * 100;
let color = "red";
if (percentage >= 90) color = "green";
else if (percentage >= 75) color = "blue";
else if (percentage >= 60) color = "orange";
return (
<Tooltip title={`${grade} из ${assignment.maxScore} (${percentage.toFixed(1)}%)`}>
<Tag color={color}>{grade}</Tag>
<Tooltip title={lesson.title}>
<div style={{fontSize: 12}}>Л{lesson.number}</div>
</Tooltip>
);
}
),
width: 70,
align: "center",
render: (record) =>
record.read_lesson_ids.includes(lesson.id) ? (
<CheckCircleFilled style={{color: "#52c41a", fontSize: 18}}/>
) : (
<ClockCircleOutlined style={{color: "#d9d9d9", fontSize: 18}}/>
),
}));
// Колонки итогов
const summaryColumns = [
{
const taskColumns = gradebook.tasks.map((task) => ({
title: (
<Space>
Средний балл
<Button
type="text"
size="small"
icon={sortConfig.key === 'average' ?
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
<SortAscendingOutlined />
}
onClick={() => handleSort('average')}
/>
</Space>
<Tooltip title={task.title}>
<div style={{fontSize: 12}}>З{task.number}</div>
</Tooltip>
),
key: "average",
width: 140,
width: 90,
align: "center",
render: (_, record) => {
const grades = Object.values(record.grades).filter(g => g !== null);
if (grades.length === 0) return <Tag>Нет оценок</Tag>;
const totalMax = assignments.reduce((sum, a) => sum + a.maxScore, 0);
const totalGrade = Object.entries(record.grades)
.reduce((sum, [assignmentId, grade]) => {
if (grade === null) return sum;
return sum + grade;
}, 0);
const percentage = (totalGrade / totalMax) * 100;
return (
<Tooltip title={`${totalGrade} из ${totalMax} баллов`}>
<Tag color={percentage >= 75 ? "green" : percentage >= 60 ? "orange" : "red"}>
{percentage.toFixed(1)}%
</Tag>
</Tooltip>
);
render: (record) => {
const grade = record.task_grades[task.id];
if (grade === null || grade === undefined) {
return <MinusOutlined style={{color: "#bfbfbf"}}/>;
}
const color = grade >= 90 ? "green" : grade >= 70 ? "orange" : "red";
return <Tag color={color}>{grade}</Tag>;
},
{
title: (
<Space>
Прогресс
<Button
type="text"
size="small"
icon={sortConfig.key === 'progress' ?
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
<SortAscendingOutlined />
}
onClick={() => handleSort('progress')}
/>
</Space>
),
key: "progress",
width: 160,
render: (_, record) => (
<Tooltip title={`${record.progress}% выполнено`}>
<Progress
percent={record.progress}
size="small"
status={record.progress < 50 ? "exception" : record.progress < 75 ? "normal" : "success"}
/>
</Tooltip>
)
}
];
}));
return [...baseColumns, ...assignmentColumns, ...summaryColumns];
}, [assignments, filters.groups, sortConfig, uniqueGroups, filters.searchText]);
const columns = [...baseColumns, ...lessonColumns, ...taskColumns];
// Статистика с учетом фильтров
const statistics = useMemo(() => {
if (!selectedCourse) {
return {
totalStudents: 0,
averageGrade: 0,
completedAssignments: 0,
totalAssignments: 0,
pendingReview: 0
};
}
const totalStudents = filteredAndSortedStudents.length;
const totalAssignments = assignments.length * filteredAndSortedStudents.length;
let completedCount = 0;
let totalGradeSum = 0;
let gradedCount = 0;
filteredAndSortedStudents.forEach(student => {
Object.values(student.grades).forEach(grade => {
if (grade !== null) {
completedCount++;
totalGradeSum += grade;
gradedCount++;
}
});
});
const averageGrade = gradedCount > 0 ? (totalGradeSum / gradedCount).toFixed(1) : 0;
const pendingReview = filteredAndSortedStudents.reduce((count, student) => {
const nullGrades = Object.values(student.grades).filter(grade => grade === null).length;
return count + nullGrades;
}, 0);
return {
totalStudents,
averageGrade,
completedAssignments: completedCount,
totalAssignments,
pendingReview
};
}, [selectedCourse, filteredAndSortedStudents, assignments]);
const handleCourseChange = (courseId) => {
setIsLoading(true);
setSelectedCourse(courseId);
// Сброс фильтров при смене курса
setFilters({
groups: [],
searchText: "",
progressRange: [0, 100]
});
setSortConfig({
key: null,
direction: 'ascend'
});
// Имитация загрузки данных
setTimeout(() => {
setIsLoading(false);
}, 500);
};
// Модальное окно поиска
const SearchModal = () => (
<Modal
title="Поиск студентов"
open={isSearchModalVisible}
onCancel={() => setIsSearchModalVisible(false)}
footer={[
<Button key="reset" onClick={() => handleSearch('')}>
Сбросить поиск
</Button>,
<Button key="cancel" onClick={() => setIsSearchModalVisible(false)}>
Отмена
</Button>,
]}
width={400}
>
<Space direction="vertical" style={{ width: '100%' }} size="middle">
<div>
<p style={{ marginBottom: 8, color: '#666' }}>
Поиск по ФИО, email или группе:
</p>
<Search
placeholder="Введите текст для поиска..."
allowClear
enterButton="Найти"
size="large"
defaultValue={filters.searchText}
onSearch={handleSearch}
/>
</div>
{filters.searchText && (
<div style={{
padding: '8px 12px',
backgroundColor: '#f0f7ff',
borderRadius: 6,
border: '1px solid #1890ff'
}}>
<span style={{ color: '#1890ff', fontSize: 12 }}>
Активный поиск: "{filters.searchText}"
</span>
</div>
)}
</Space>
</Modal>
);
const dataSource = visibleStudents.map((student) => ({
key: student.student_id,
...student,
}));
return (
<div style={{ padding: 24, backgroundColor: '#ffffff', minHeight: '100vh' }}>
{/* Шапка с навигацией */}
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Col>
<Title level={2} style={{ margin: 0, color: '#262626' }}>Электронный журнал</Title>
</Col>
</Row>
{/* Выбор курса */}
<Card
style={{
marginBottom: 24,
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
styles={{ body: { padding: '16px 24px' } }}
>
<Space size="middle" align="center">
<span style={{ fontWeight: 500, fontSize: 16 }}>Выберите курс:</span>
<Select
placeholder="Выберите курс"
style={{ width: 300 }}
onChange={handleCourseChange}
options={courses.map(course => ({
value: course.id,
label: course.title
}))}
size="large"
/>
</Space>
</Card>
{/* Статистика */}
{selectedCourse && (
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={4}>
<Card
style={{ borderRadius: 8, textAlign: 'center' }}
styles={{ body: { padding: '20px 16px' } }}
>
<Statistic
title="Всего студентов"
value={statistics.totalStudents}
styles={{ content: { color: '#1890ff', fontSize: '28px' } }}
/>
</Card>
</Col>
<Col span={5}>
<Card
style={{ borderRadius: 8, textAlign: 'center' }}
styles={{ body: { padding: '20px 16px' } }}
>
<Statistic
title="Средний балл"
value={statistics.averageGrade}
styles={{ content: { color: '#52c41a', fontSize: '28px' } }}
suffix="баллов"
/>
</Card>
</Col>
<Col span={5}>
<Card
style={{ borderRadius: 8, textAlign: 'center' }}
styles={{ body: { padding: '20px 16px' } }}
>
<Statistic
title="Выполнено заданий"
value={statistics.completedAssignments}
styles={{ content: { color: '#fa8c16', fontSize: '28px' } }}
suffix={`/ ${statistics.totalAssignments}`}
/>
</Card>
</Col>
<Col span={5}>
<Card
style={{ borderRadius: 8, textAlign: 'center' }}
styles={{ body: { padding: '20px 16px' } }}
>
<Statistic
title="На проверке"
value={statistics.pendingReview}
styles={{ content: { color: '#fa541c', fontSize: '28px' } }}
/>
</Card>
</Col>
<Col span={5}>
<Card
style={{ borderRadius: 8, textAlign: 'center' }}
styles={{ body: { padding: '20px 16px' } }}
>
<Statistic
title="Процент выполнения"
value={((statistics.completedAssignments / statistics.totalAssignments) * 100).toFixed(1)}
styles={{ content: { color: '#722ed1', fontSize: '28px' } }}
suffix="%"
/>
</Card>
</Col>
</Row>
)}
{/* Информация о выбранном курсе */}
{selectedCourse && (
<Card
style={{
marginBottom: 24,
borderRadius: 8,
backgroundColor: '#f0f7ff',
border: '1px solid #1890ff'
}}
styles={{ body: { padding: '16px 24px' } }}
>
<Row justify="space-between" align="middle">
<Col>
<Space>
<Title level={4} style={{ margin: 0, color: '#1890ff' }}>
{courses.find(c => c.id === selectedCourse)?.title}
</Title>
<Tag color="blue" style={{ fontSize: 14, padding: '4px 8px' }}>
Активный курс
</Tag>
</Space>
</Col>
<Col>
<Space>
{filters.groups.length > 0 && (
<Badge count={filters.groups.length} showZero={false}>
<Tag color="orange">Фильтр по группам</Tag>
</Badge>
)}
{filters.searchText && (
<Tag color="purple">
<Space>
<SearchOutlined />
Поиск: "{filters.searchText}"
</Space>
</Tag>
)}
{(filters.groups.length > 0 || filters.searchText) && (
<Button
type="text"
size="small"
onClick={() => {
setFilters({
groups: [],
searchText: "",
progressRange: [0, 100]
});
}}
>
Сбросить фильтры
<div style={{padding: 24, backgroundColor: "#f5f5f5", minHeight: "100vh"}}>
<Card style={{marginBottom: 24}}>
<Space direction="vertical" size="middle" style={{width: "100%"}}>
<Button icon={<ArrowLeftOutlined/>} onClick={() => navigate(-1)} type="text">
Назад
</Button>
<Title level={2} style={{margin: 0}}>
{isStudent ? "Моя успеваемость" : "Журнал успеваемости"} {courseData?.title}
</Title>
{!isStudent && (
<Space size="large">
<Statistic title="Студентов" value={gradebook.students.length}/>
<Statistic title="Лекций" value={gradebook.lessons.length}/>
<Statistic title="Заданий" value={gradebook.tasks.length}/>
</Space>
)}
</Space>
</Col>
</Row>
</Card>
)}
{/* Таблица студентов */}
{selectedCourse && (
<Card
style={{
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
styles={{ body: { padding: 0 } }}
>
<Card>
<Table
columns={columns}
dataSource={filteredAndSortedStudents.map(student => ({ ...student, key: student.id }))}
loading={isLoading}
scroll={{ x: 1200 }}
pagination={{
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`Показано ${range[0]}-${range[1]} из ${total} студентов`,
pageSizeOptions: ['10', '20', '50'],
style: { marginTop: 16, marginRight: 16 }
}}
dataSource={dataSource}
scroll={{x: isStudent ? 1000 : 1400}}
pagination={isStudent ? false : {pageSize: 15, showSizeChanger: true}}
bordered
size="middle"
/>
</Card>
)}
{/* Сообщение при невыбранном курсе */}
{!selectedCourse && (
<Card
style={{
textAlign: 'center',
borderRadius: 8,
backgroundColor: '#fafafa'
}}
styles={{ body: { padding: '60px' } }}
>
<Title level={3} style={{ color: '#8c8c8c' }}>
Выберите курс для просмотра журнала
</Title>
<p style={{ color: '#8c8c8c', fontSize: 16 }}>
Пожалуйста, выберите курс из выпадающего списка выше чтобы увидеть успеваемость студентов
</p>
{!isStudent && (
<Card style={{marginTop: 24}}>
<Text>Легенда: </Text>
<Tag color="green">90100</Tag>
<Tag color="orange">7089</Tag>
<Tag color="red">069</Tag>
<Text strong style={{marginLeft: 16}}>
<CheckCircleFilled style={{color: "#52c41a"}}/> прочитано
</Text>
<Text strong>
<ClockCircleOutlined style={{color: "#d9d9d9"}}/> не прочитано
</Text>
<Text strong>
<MinusOutlined/> не сдано / не оценено
</Text>
</Card>
)}
{/* Модальное окно поиска */}
<SearchModal />
</div>
);
};