добавил журнал успеваемости
This commit is contained in:
parent
f4e3cbf3be
commit
8f44b12021
@ -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)
|
||||
|
||||
44
api/app/domain/entities/gradebook.py
Normal file
44
api/app/domain/entities/gradebook.py
Normal 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
|
||||
97
api/app/infrastructure/gradebook_service.py
Normal file
97
api/app/infrastructure/gradebook_service.py
Normal 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())
|
||||
)
|
||||
@ -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;
|
||||
@ -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}`,
|
||||
|
||||
@ -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>
|
||||
)
|
||||
};
|
||||
|
||||
@ -1,723 +1,217 @@
|
||||
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 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";
|
||||
const isLoading = courseLoading || gradebookLoading || userLoading;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Tooltip title={`${grade} из ${assignment.maxScore} (${percentage.toFixed(1)}%)`}>
|
||||
<Tag color={color}>{grade}</Tag>
|
||||
</Tooltip>
|
||||
<div style={{padding: 80, textAlign: "center"}}>
|
||||
<Spin size="large" tip="Загружается журнал..."/>
|
||||
</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];
|
||||
}, [assignments, filters.groups, sortConfig, uniqueGroups, filters.searchText]);
|
||||
const lessonColumns = gradebook.lessons.map((lesson) => ({
|
||||
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 statistics = useMemo(() => {
|
||||
if (!selectedCourse) {
|
||||
return {
|
||||
totalStudents: 0,
|
||||
averageGrade: 0,
|
||||
completedAssignments: 0,
|
||||
totalAssignments: 0,
|
||||
pendingReview: 0
|
||||
};
|
||||
}
|
||||
const taskColumns = gradebook.tasks.map((task) => ({
|
||||
title: (
|
||||
<Tooltip title={task.title}>
|
||||
<div style={{fontSize: 12}}>З{task.number}</div>
|
||||
</Tooltip>
|
||||
),
|
||||
width: 90,
|
||||
align: "center",
|
||||
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 totalAssignments = assignments.length * filteredAndSortedStudents.length;
|
||||
const columns = [...baseColumns, ...lessonColumns, ...taskColumns];
|
||||
|
||||
let completedCount = 0;
|
||||
let totalGradeSum = 0;
|
||||
let gradedCount = 0;
|
||||
const dataSource = visibleStudents.map((student) => ({
|
||||
key: student.student_id,
|
||||
...student,
|
||||
}));
|
||||
|
||||
filteredAndSortedStudents.forEach(student => {
|
||||
Object.values(student.grades).forEach(grade => {
|
||||
if (grade !== null) {
|
||||
completedCount++;
|
||||
totalGradeSum += grade;
|
||||
gradedCount++;
|
||||
}
|
||||
});
|
||||
});
|
||||
return (
|
||||
<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>
|
||||
|
||||
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);
|
||||
<Title level={2} style={{margin: 0}}>
|
||||
{isStudent ? "Моя успеваемость" : "Журнал успеваемости"} — {courseData?.title}
|
||||
</Title>
|
||||
|
||||
return {
|
||||
totalStudents,
|
||||
averageGrade,
|
||||
completedAssignments: completedCount,
|
||||
totalAssignments,
|
||||
pendingReview
|
||||
};
|
||||
}, [selectedCourse, filteredAndSortedStudents, assignments]);
|
||||
{!isStudent && (
|
||||
<Space size="large">
|
||||
<Statistic title="Студентов" value={gradebook.students.length}/>
|
||||
<Statistic title="Лекций" value={gradebook.lessons.length}/>
|
||||
<Statistic title="Заданий" value={gradebook.tasks.length}/>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
const handleCourseChange = (courseId) => {
|
||||
setIsLoading(true);
|
||||
setSelectedCourse(courseId);
|
||||
<Card>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
scroll={{x: isStudent ? 1000 : 1400}}
|
||||
pagination={isStudent ? false : {pageSize: 15, showSizeChanger: true}}
|
||||
bordered
|
||||
size="middle"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
// Сброс фильтров при смене курса
|
||||
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}
|
||||
/>
|
||||
{!isStudent && (
|
||||
<Card style={{marginTop: 24}}>
|
||||
<Text>Легенда: </Text>
|
||||
<Tag color="green">90–100</Tag>
|
||||
<Tag color="orange">70–89</Tag>
|
||||
<Tag color="red">0–69</Tag>
|
||||
<Text strong style={{marginLeft: 16}}>
|
||||
<CheckCircleFilled style={{color: "#52c41a"}}/> — прочитано
|
||||
</Text>
|
||||
<Text strong>
|
||||
<ClockCircleOutlined style={{color: "#d9d9d9"}}/> — не прочитано
|
||||
</Text>
|
||||
<Text strong>
|
||||
<MinusOutlined/> — не сдано / не оценено
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
</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;
|
||||
Loading…
x
Reference in New Issue
Block a user