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

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.course_teachers import CourseTeacherRead, CourseTeacherCreate
from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated from app.domain.entities.courses import CourseRead, CourseCreate, CourseUpdate, CourseCreated
from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate from app.domain.entities.enrollments import EnrollmentRead, EnrollmentCreate
from app.domain.entities.gradebook import GradeBookRead
from app.domain.models import User from app.domain.models import User
from app.infrastructure.course_teachers_service import CourseTeachersService from app.infrastructure.course_teachers_service import CourseTeachersService
from app.infrastructure.courses_service import CoursesService from app.infrastructure.courses_service import CoursesService
from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin from app.infrastructure.dependencies import require_auth_user, require_teacher, require_admin
from app.infrastructure.enrollments_service import EnrollmentsService from app.infrastructure.enrollments_service import EnrollmentsService
from app.infrastructure.gradebook_service import GradeBookService
courses_router = APIRouter() courses_router = APIRouter()
@ -152,3 +154,13 @@ async def replace_course_students(
): ):
service = EnrollmentsService(db) service = EnrollmentsService(db)
return await service.replace_course_students_list(students, course_id) 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'], invalidatesTags: ['student'],
}), }),
getGradebookByCourse: builder.query({
query: (courseId) => ({
url: `/courses/gradebook/${courseId}/`,
method: "GET",
}),
providesTags: ['gradebook'],
}),
}), }),
}); });
@ -87,4 +94,5 @@ export const {
useReplaceCourseTeachersMutation, useReplaceCourseTeachersMutation,
useGetCourseStudentsQuery, useGetCourseStudentsQuery,
useReplaceCourseStudentsMutation, useReplaceCourseStudentsMutation,
useGetGradebookByCourseQuery,
} = coursesApi; } = 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', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${token}`, 'Authorization': `Bearer ${token}`,

View File

@ -5,7 +5,7 @@ import {
Card, Card,
Col, Col,
Empty, Empty,
FloatButton, FloatButton, Grid,
Popconfirm, Popconfirm,
Result, Result,
Row, Row,
@ -19,8 +19,8 @@ import {
BookOutlined, CheckCircleFilled, ClockCircleOutlined, BookOutlined, CheckCircleFilled, ClockCircleOutlined,
DeleteOutlined, DeleteOutlined,
EditOutlined, EditOutlined,
FormOutlined, FormOutlined, MenuFoldOutlined, MenuUnfoldOutlined,
PlusOutlined PlusOutlined, TableOutlined
} from "@ant-design/icons"; } from "@ant-design/icons";
import {useNavigate, useParams} from "react-router-dom"; import {useNavigate, useParams} from "react-router-dom";
import {ROLES} from "../../../Core/constants.js"; import {ROLES} from "../../../Core/constants.js";
@ -32,12 +32,17 @@ import UpdateLessonModalForm from "./Components/UpdateLessonModalForm/UpdateLess
import CreateTaskModalForm from "./Components/CreateTaskModalForm/CreateTaskModalForm.jsx"; import CreateTaskModalForm from "./Components/CreateTaskModalForm/CreateTaskModalForm.jsx";
import UpdateTaskModalForm from "./Components/UpdateTaskModalForm/UpdateTaskModalForm.jsx"; import UpdateTaskModalForm from "./Components/UpdateTaskModalForm/UpdateTaskModalForm.jsx";
import ViewTaskModal from "./Components/ViewTaskModalForm/ViewTaskModal.jsx"; import ViewTaskModal from "./Components/ViewTaskModalForm/ViewTaskModal.jsx";
import {useState} from "react";
const {Title, Text} = Typography; const {Title, Text} = Typography;
const {useBreakpoint} = Grid;
const CourseDetailPage = () => { const CourseDetailPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const screens = useBreakpoint();
const [hovered, setHovered] = useState(false);
const {courseId} = useParams(); const {courseId} = useParams();
const { const {
tasksData, tasksData,
@ -242,6 +247,39 @@ const CourseDetailPage = () => {
<ViewTaskModal <ViewTaskModal
courseId={courseId} 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/> <UpdateTaskModalForm/>
{[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && ( {[CONFIG.ROOT_ROLE_NAME, ROLES.TEACHER].includes(userData.role.title) && (
<FloatButton.Group <FloatButton.Group
@ -262,7 +300,9 @@ const CourseDetailPage = () => {
onClick={handleCreateTask} onClick={handleCreateTask}
/> />
</FloatButton.Group> </FloatButton.Group>
)}
)
}
</div> </div>
) )
}; };

View File

@ -1,721 +1,215 @@
import { useState, useMemo } from "react"; import {useParams, useNavigate} from "react-router-dom";
import { Avatar, Progress, Tag, Tooltip, Space, Button, Table, Card, Row, Col, Statistic, Select, Typography, Input, Modal, Badge } from "antd"; import {
import { UserOutlined, SearchOutlined, SortAscendingOutlined, SortDescendingOutlined, FilterOutlined } from "@ant-design/icons"; 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 {Title, Text} = Typography;
const { Search } = Input;
const GradebookPage = ({ onLogout }) => { const GradebookPage = () => {
const [selectedCourse, setSelectedCourse] = useState(null); const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false); const {courseId} = useParams();
const [filters, setFilters] = useState({
groups: [],
searchText: "",
progressRange: [0, 100]
});
const [sortConfig, setSortConfig] = useState({
key: null,
direction: 'ascend'
});
const [isSearchModalVisible, setIsSearchModalVisible] = useState(false);
// Заглушки для данных // Данные текущего пользователя
const courses = [ const {data: userData, isLoading: userLoading} = useGetAuthenticatedUserDataQuery();
{ id: 1, title: "Основы программирования" },
{ id: 2, title: "Веб-разработка" },
{ id: 3, title: "Базы данных" },
{ id: 4, title: "Алгоритмы и структуры данных" },
];
const assignments = [ const {
{ id: 1, name: "ДЗ №1", maxScore: 100 }, data: courseData,
{ id: 2, name: "ДЗ №2", maxScore: 100 }, isLoading: courseLoading,
{ id: 3, name: "ДЗ №3", maxScore: 100 }, } = useGetCourseByIdQuery(courseId);
{ id: 4, name: "Проект", maxScore: 200 },
];
const students = [ const {
{ data: gradebook,
id: 1, isLoading: gradebookLoading,
firstName: "Иван", isError: gradebookError,
lastName: "Иванов", } = useGetGradebookByCourseQuery(courseId, {pollingInterval: 15000});
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 isLoading = courseLoading || gradebookLoading || userLoading;
const uniqueGroups = useMemo(() => {
return [...new Set(students.map(student => student.group))];
}, [students]);
// Фильтрация и сортировка данных if (isLoading) {
const filteredAndSortedStudents = useMemo(() => { return (
let filtered = students.filter(student => { <div style={{padding: 80, textAlign: "center"}}>
// Фильтр по группам <Spin size="large" tip="Загружается журнал..."/>
if (filters.groups.length > 0 && !filters.groups.includes(student.group)) { </div>
return false; );
} }
// Поиск по тексту (ФИО, email, группа) if (gradebookError || !gradebook) {
if (filters.searchText) { return <Empty description="Не удалось загрузить журнал"/>;
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;
}
} }
// Фильтр по прогрессу const isStudent = userData?.role?.title === "student";
if (student.progress < filters.progressRange[0] || student.progress > filters.progressRange[1]) { const currentStudentId = userData?.id;
return false;
// Если студент фильтруем только его данные
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 = [ const baseColumns = [
{ {
title: ( title: "№",
<Space> width: 60,
Студент
<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",
fixed: "left", fixed: "left",
width: 250, render: (_, __, index) => index + 1,
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>
),
}, },
{ {
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> <Space>
Группа <Avatar icon={<UserOutlined/>} style={{backgroundColor: "#1890ff"}}/>
<Button <div>
type="text" <Text strong>{fullName}</Text>
size="small" <br/>
icon={sortConfig.key === 'group' ? <Text type="secondary">Ваша успеваемость</Text>
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) : </div>
<SortAscendingOutlined />
}
onClick={() => handleSort('group')}
/>
</Space> </Space>
), );
dataIndex: "group", }
key: "group", return <Text strong>{fullName}</Text>;
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,
}, },
]; ];
// Колонки для заданий const lessonColumns = gradebook.lessons.map((lesson) => ({
const assignmentColumns = assignments.map(assignment => ({
title: ( title: (
<Space> <Tooltip title={lesson.title}>
{assignment.name} <div style={{fontSize: 12}}>Л{lesson.number}</div>
<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> </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 taskColumns = gradebook.tasks.map((task) => ({
const summaryColumns = [
{
title: ( title: (
<Space> <Tooltip title={task.title}>
Средний балл <div style={{fontSize: 12}}>З{task.number}</div>
<Button </Tooltip>
type="text"
size="small"
icon={sortConfig.key === 'average' ?
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
<SortAscendingOutlined />
}
onClick={() => handleSort('average')}
/>
</Space>
), ),
key: "average", width: 90,
width: 140,
align: "center", align: "center",
render: (_, record) => { render: (record) => {
const grades = Object.values(record.grades).filter(g => g !== null); const grade = record.task_grades[task.id];
if (grades.length === 0) return <Tag>Нет оценок</Tag>; if (grade === null || grade === undefined) {
return <MinusOutlined style={{color: "#bfbfbf"}}/>;
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>
);
} }
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]; const columns = [...baseColumns, ...lessonColumns, ...taskColumns];
}, [assignments, filters.groups, sortConfig, uniqueGroups, filters.searchText]);
// Статистика с учетом фильтров const dataSource = visibleStudents.map((student) => ({
const statistics = useMemo(() => { key: student.student_id,
if (!selectedCourse) { ...student,
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>
);
return ( return (
<div style={{ padding: 24, backgroundColor: '#ffffff', minHeight: '100vh' }}> <div style={{padding: 24, backgroundColor: "#f5f5f5", minHeight: "100vh"}}>
{/* Шапка с навигацией */} <Card style={{marginBottom: 24}}>
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}> <Space direction="vertical" size="middle" style={{width: "100%"}}>
<Col> <Button icon={<ArrowLeftOutlined/>} onClick={() => navigate(-1)} type="text">
<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]
});
}}
>
Сбросить фильтры
</Button> </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> </Space>
</Col>
</Row>
</Card> </Card>
)}
{/* Таблица студентов */} <Card>
{selectedCourse && (
<Card
style={{
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
styles={{ body: { padding: 0 } }}
>
<Table <Table
columns={columns} columns={columns}
dataSource={filteredAndSortedStudents.map(student => ({ ...student, key: student.id }))} dataSource={dataSource}
loading={isLoading} scroll={{x: isStudent ? 1000 : 1400}}
scroll={{ x: 1200 }} pagination={isStudent ? false : {pageSize: 15, showSizeChanger: true}}
pagination={{ bordered
pageSize: 10,
showSizeChanger: true,
showQuickJumper: true,
showTotal: (total, range) =>
`Показано ${range[0]}-${range[1]} из ${total} студентов`,
pageSizeOptions: ['10', '20', '50'],
style: { marginTop: 16, marginRight: 16 }
}}
size="middle" size="middle"
/> />
</Card> </Card>
)}
{/* Сообщение при невыбранном курсе */} {!isStudent && (
{!selectedCourse && ( <Card style={{marginTop: 24}}>
<Card <Text>Легенда: </Text>
style={{ <Tag color="green">90100</Tag>
textAlign: 'center', <Tag color="orange">7089</Tag>
borderRadius: 8, <Tag color="red">069</Tag>
backgroundColor: '#fafafa' <Text strong style={{marginLeft: 16}}>
}} <CheckCircleFilled style={{color: "#52c41a"}}/> прочитано
styles={{ body: { padding: '60px' } }} </Text>
> <Text strong>
<Title level={3} style={{ color: '#8c8c8c' }}> <ClockCircleOutlined style={{color: "#d9d9d9"}}/> не прочитано
Выберите курс для просмотра журнала </Text>
</Title> <Text strong>
<p style={{ color: '#8c8c8c', fontSize: 16 }}> <MinusOutlined/> не сдано / не оценено
Пожалуйста, выберите курс из выпадающего списка выше чтобы увидеть успеваемость студентов </Text>
</p>
</Card> </Card>
)} )}
{/* Модальное окно поиска */}
<SearchModal />
</div> </div>
); );
}; };