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

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,723 +1,217 @@
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]);
// Фильтрация и сортировка данных
const filteredAndSortedStudents = useMemo(() => {
let filtered = students.filter(student => {
// Фильтр по группам
if (filters.groups.length > 0 && !filters.groups.includes(student.group)) {
return false;
}
// Поиск по тексту (ФИО, 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 (student.progress < filters.progressRange[0] || student.progress > filters.progressRange[1]) {
return false;
}
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",
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>
),
},
{
title: (
<Space>
Группа
<Button
type="text"
size="small"
icon={sortConfig.key === 'group' ?
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
<SortAscendingOutlined />
}
onClick={() => handleSort('group')}
/>
</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,
},
];
// Колонки для заданий
const assignmentColumns = assignments.map(assignment => ({
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";
if (isLoading) {
return ( return (
<Tooltip title={`${grade} из ${assignment.maxScore} (${percentage.toFixed(1)}%)`}> <div style={{padding: 80, textAlign: "center"}}>
<Tag color={color}>{grade}</Tag> <Spin size="large" tip="Загружается журнал..."/>
</Tooltip> </div>
); );
}
}));
// Колонки итогов
const summaryColumns = [
{
title: (
<Space>
Средний балл
<Button
type="text"
size="small"
icon={sortConfig.key === 'average' ?
(sortConfig.direction === 'ascend' ? <SortAscendingOutlined /> : <SortDescendingOutlined />) :
<SortAscendingOutlined />
}
onClick={() => handleSort('average')}
/>
</Space>
),
key: "average",
width: 140,
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>
);
}
},
{
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>
)
} }
if (gradebookError || !gradebook) {
return <Empty description="Не удалось загрузить журнал"/>;
}
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>
);
}
// === Колонки ===
const baseColumns = [
{
title: "№",
width: 60,
fixed: "left",
render: (_, __, index) => index + 1,
},
{
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>
<Avatar icon={<UserOutlined/>} style={{backgroundColor: "#1890ff"}}/>
<div>
<Text strong>{fullName}</Text>
<br/>
<Text type="secondary">Ваша успеваемость</Text>
</div>
</Space>
);
}
return <Text strong>{fullName}</Text>;
},
},
]; ];
return [...baseColumns, ...assignmentColumns, ...summaryColumns]; const lessonColumns = gradebook.lessons.map((lesson) => ({
}, [assignments, filters.groups, sortConfig, uniqueGroups, filters.searchText]); title: (
<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 taskColumns = gradebook.tasks.map((task) => ({
const statistics = useMemo(() => { title: (
if (!selectedCourse) { <Tooltip title={task.title}>
return { <div style={{fontSize: 12}}>З{task.number}</div>
totalStudents: 0, </Tooltip>
averageGrade: 0, ),
completedAssignments: 0, width: 90,
totalAssignments: 0, align: "center",
pendingReview: 0 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>;
},
}));
const totalStudents = filteredAndSortedStudents.length; const columns = [...baseColumns, ...lessonColumns, ...taskColumns];
const totalAssignments = assignments.length * filteredAndSortedStudents.length;
let completedCount = 0;
let totalGradeSum = 0;
let gradedCount = 0;
filteredAndSortedStudents.forEach(student => { const dataSource = visibleStudents.map((student) => ({
Object.values(student.grades).forEach(grade => { key: student.student_id,
if (grade !== null) { ...student,
completedCount++; }));
totalGradeSum += grade;
gradedCount++;
}
});
});
const averageGrade = gradedCount > 0 ? (totalGradeSum / gradedCount).toFixed(1) : 0; return (
const pendingReview = filteredAndSortedStudents.reduce((count, student) => { <div style={{padding: 24, backgroundColor: "#f5f5f5", minHeight: "100vh"}}>
const nullGrades = Object.values(student.grades).filter(grade => grade === null).length; <Card style={{marginBottom: 24}}>
return count + nullGrades; <Space direction="vertical" size="middle" style={{width: "100%"}}>
}, 0); <Button icon={<ArrowLeftOutlined/>} onClick={() => navigate(-1)} type="text">
Назад
</Button>
return { <Title level={2} style={{margin: 0}}>
totalStudents, {isStudent ? "Моя успеваемость" : "Журнал успеваемости"} {courseData?.title}
averageGrade, </Title>
completedAssignments: completedCount,
totalAssignments,
pendingReview
};
}, [selectedCourse, filteredAndSortedStudents, assignments]);
const handleCourseChange = (courseId) => { {!isStudent && (
setIsLoading(true); <Space size="large">
setSelectedCourse(courseId); <Statistic title="Студентов" value={gradebook.students.length}/>
<Statistic title="Лекций" value={gradebook.lessons.length}/>
// Сброс фильтров при смене курса <Statistic title="Заданий" value={gradebook.tasks.length}/>
setFilters({ </Space>
groups: [], )}
searchText: "", </Space>
progressRange: [0, 100] </Card>
});
setSortConfig({
key: null,
direction: 'ascend'
});
// Имитация загрузки данных <Card>
setTimeout(() => { <Table
setIsLoading(false); columns={columns}
}, 500); dataSource={dataSource}
}; scroll={{x: isStudent ? 1000 : 1400}}
pagination={isStudent ? false : {pageSize: 15, showSizeChanger: true}}
bordered
size="middle"
/>
</Card>
// Модальное окно поиска {!isStudent && (
const SearchModal = () => ( <Card style={{marginTop: 24}}>
<Modal <Text>Легенда: </Text>
title="Поиск студентов" <Tag color="green">90100</Tag>
open={isSearchModalVisible} <Tag color="orange">7089</Tag>
onCancel={() => setIsSearchModalVisible(false)} <Tag color="red">069</Tag>
footer={[ <Text strong style={{marginLeft: 16}}>
<Button key="reset" onClick={() => handleSearch('')}> <CheckCircleFilled style={{color: "#52c41a"}}/> прочитано
Сбросить поиск </Text>
</Button>, <Text strong>
<Button key="cancel" onClick={() => setIsSearchModalVisible(false)}> <ClockCircleOutlined style={{color: "#d9d9d9"}}/> не прочитано
Отмена </Text>
</Button>, <Text strong>
]} <MinusOutlined/> не сдано / не оценено
width={400} </Text>
> </Card>
<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> </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 (
<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]
});
}}
>
Сбросить фильтры
</Button>
)}
</Space>
</Col>
</Row>
</Card>
)}
{/* Таблица студентов */}
{selectedCourse && (
<Card
style={{
borderRadius: 8,
boxShadow: '0 2px 8px rgba(0,0,0,0.1)'
}}
styles={{ body: { padding: 0 } }}
>
<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 }
}}
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>
</Card>
)}
{/* Модальное окно поиска */}
<SearchModal />
</div>
);
}; };
export default GradebookPage; export default GradebookPage;