From 3b51ebe5681c1db69a4a269caa843d7e3ca3ef0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Mon, 2 Mar 2026 22:15:45 +0300 Subject: [PATCH 01/39] feat: add tab for course groups --- hwproj.front/src/components/Courses/Course.tsx | 17 ++++++++++++++--- .../src/components/Courses/CourseGroups.tsx | 10 ++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 hwproj.front/src/components/Courses/CourseGroups.tsx diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 5440ded7c..0915f4eeb 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -34,11 +34,12 @@ import {MoreVert} from "@mui/icons-material"; import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; import {CourseUnitType} from "@/components/Files/CourseUnitType"; +import CourseGroups from "./CourseGroups"; -type TabValue = "homeworks" | "stats" | "applications" +type TabValue = "homeworks" | "stats" | "applications" | "groups" function isAcceptableTabValue(str: string): str is TabValue { - return str === "homeworks" || str === "stats" || str === "applications"; + return str === "homeworks" || str === "stats" || str === "applications" || str === "groups"; } interface ICourseState { @@ -302,12 +303,13 @@ const Course: React.FC = () => { style={{marginBottom: 10}} variant="scrollable" scrollButtons={"auto"} - value={tabValue === "homeworks" ? 0 : tabValue === "stats" ? 1 : 2} + value={tabValue === "homeworks" ? 0 : tabValue === "stats" ? 1 : tabValue === "applications" ? 2 : 3} indicatorColor="primary" onChange={(event, value) => { if (value === 0 && !isExpert) navigate(`/courses/${courseId}/homeworks`) if (value === 1) navigate(`/courses/${courseId}/stats`) if (value === 2 && !isExpert) navigate(`/courses/${courseId}/applications`) + if (value === 3) navigate(`/courses/${courseId}/groups`) }} > {!isExpert && @@ -325,6 +327,12 @@ const Course: React.FC = () => { }/>} + {isCourseMentor && +
Группы
+ + }/>} {tabValue === "homeworks" && { courseId={courseId!} /> } + {tabValue === "groups" && isCourseMentor && + + } ); diff --git a/hwproj.front/src/components/Courses/CourseGroups.tsx b/hwproj.front/src/components/Courses/CourseGroups.tsx new file mode 100644 index 000000000..75af2dc49 --- /dev/null +++ b/hwproj.front/src/components/Courses/CourseGroups.tsx @@ -0,0 +1,10 @@ +import {FC} from "react"; +import {Card, CardContent, CardActions, Grid, Button, Typography, Alert, AlertTitle} from '@mui/material'; + +interface ICourseGroupsProps {} + +const CourseGroups: FC = (props) => { + return +} + +export default CourseGroups; From 15a56185567c526ce71a44d2faa22d19b4e6e9c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Wed, 4 Mar 2026 22:09:15 +0300 Subject: [PATCH 02/39] feat: add groups with names get method --- .../Controllers/CourseGroupsController.cs | 11 +++ .../CoursesService/DTO/GroupWithNameDTO.cs | 9 ++ .../Controllers/CourseGroupsController.cs | 17 +++- .../CoursesServiceClient.cs | 10 ++ .../ICoursesServiceClient.cs | 1 + hwproj.front/src/api/api.ts | 93 +++++++++++++++++++ 6 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 HwProj.Common/HwProj.Models/CoursesService/DTO/GroupWithNameDTO.cs diff --git a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/CourseGroupsController.cs b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/CourseGroupsController.cs index 4d1bbd78f..7225b64d7 100644 --- a/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/CourseGroupsController.cs +++ b/HwProj.APIGateway/HwProj.APIGateway.API/Controllers/CourseGroupsController.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using HwProj.CoursesService.Client; using HwProj.Models.CoursesService.ViewModels; +using HwProj.Models.CoursesService.DTO; using HwProj.Models.Roles; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -30,6 +31,16 @@ public async Task GetAllCourseGroups(long courseId) : Ok(result); } + [HttpGet("{courseId}/getAllWithNames")] + [ProducesResponseType(typeof(GroupWithNameDTO[]), (int)HttpStatusCode.OK)] + public async Task GetAllCourseGroupsWithNames(long courseId) + { + var result = await _coursesClient.GetAllCourseGroupsWithNames(courseId); + return result == null + ? NotFound() + : Ok(result); + } + [HttpPost("{courseId}/create")] [Authorize(Roles = Roles.LecturerRole)] [ProducesResponseType(typeof(long), (int)HttpStatusCode.OK)] diff --git a/HwProj.Common/HwProj.Models/CoursesService/DTO/GroupWithNameDTO.cs b/HwProj.Common/HwProj.Models/CoursesService/DTO/GroupWithNameDTO.cs new file mode 100644 index 000000000..89baefaf3 --- /dev/null +++ b/HwProj.Common/HwProj.Models/CoursesService/DTO/GroupWithNameDTO.cs @@ -0,0 +1,9 @@ +namespace HwProj.Models.CoursesService.DTO +{ + public class GroupWithNameDTO + { + public long Id { get; set; } + public string Name { get; set; } + public string[] StudentsIds { get; set; } + } +} \ No newline at end of file diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/CourseGroupsController.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/CourseGroupsController.cs index f99ce1d42..d46c1a450 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/CourseGroupsController.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Controllers/CourseGroupsController.cs @@ -1,9 +1,11 @@ -using System.Linq; +using System; +using System.Linq; using System.Threading.Tasks; using AutoMapper; using HwProj.CoursesService.API.Filters; using HwProj.CoursesService.API.Models; using HwProj.CoursesService.API.Services; +using HwProj.Models.CoursesService.DTO; using HwProj.Models.CoursesService.ViewModels; using Microsoft.AspNetCore.Mvc; @@ -36,6 +38,19 @@ public async Task GetAll(long courseId) return result; } + [HttpGet("{courseId}/getAllWithNames")] + public async Task GetAllWithNames(long courseId) + { + var groups = await _groupsService.GetAllAsync(courseId); + var result = groups.Select(t => new GroupWithNameDTO + { + Id = t.Id, + Name = t.Name, + StudentsIds = t.GroupMates.Select(s => s.StudentId).ToArray() + }).ToArray(); + return result; + } + [HttpPost("{courseId}/create")] public async Task CreateGroup([FromBody] CreateGroupViewModel groupViewModel) { diff --git a/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs b/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs index 2cbd55fd2..68d3c6d39 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.Client/CoursesServiceClient.cs @@ -414,6 +414,16 @@ public async Task GetAllCourseGroups(long courseId) return await response.DeserializeAsync(); } + public async Task GetAllCourseGroupsWithNames(long courseId) + { + using var httpRequest = new HttpRequestMessage( + HttpMethod.Get, + _coursesServiceUri + $"api/CourseGroups/{courseId}/getAllWithNames"); + + var response = await _httpClient.SendAsync(httpRequest); + return await response.DeserializeAsync(); + } + public async Task CreateCourseGroup(CreateGroupViewModel model, long courseId) { using var httpRequest = new HttpRequestMessage( diff --git a/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs b/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs index da84eb73b..ddcde0582 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.Client/ICoursesServiceClient.cs @@ -37,6 +37,7 @@ Task UpdateStudentCharacteristics(long courseId, string studentId, Task DeleteTask(long taskId); Task> UpdateTask(long taskId, PostTaskViewModel taskViewModel); Task GetAllCourseGroups(long courseId); + Task GetAllCourseGroupsWithNames(long courseId); Task CreateCourseGroup(CreateGroupViewModel model, long courseId); Task DeleteCourseGroup(long courseId, long groupId); Task UpdateCourseGroup(UpdateGroupViewModel model, long courseId, long groupId); diff --git a/hwproj.front/src/api/api.ts b/hwproj.front/src/api/api.ts index e198db7c5..04a3e0fae 100644 --- a/hwproj.front/src/api/api.ts +++ b/hwproj.front/src/api/api.ts @@ -1065,6 +1065,31 @@ export interface GroupViewModel { */ studentsIds?: Array; } +/** + * + * @export + * @interface Group + */ +export interface Group { + /** + * + * @type {string} + * @memberof Group + */ + name?: string; + /** + * + * @type {number} + * @memberof Group + */ + id?: number; + /** + * + * @type {Array} + * @memberof Group + */ + studentsIds?: Array; +} /** * * @export @@ -3969,6 +3994,36 @@ export const CourseGroupsApiFetchParamCreator = function (configuration?: Config options: localVarRequestOptions, }; }, + courseGroupsGetAllCourseGroupsWithNames(courseId: number, options: any = {}): FetchArgs { + // verify required parameter 'courseId' is not null or undefined + if (courseId === null || courseId === undefined) { + throw new RequiredError('courseId','Required parameter courseId was null or undefined when calling courseGroupsGetAllCourseGroupsWithNames.'); + } + const localVarPath = `/api/CourseGroups/{courseId}/getAllWithNames` + .replace(`{${"courseId"}}`, encodeURIComponent(String(courseId))); + const localVarUrlObj = url.parse(localVarPath, true); + const localVarRequestOptions = Object.assign({ method: 'GET' }, options); + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication Bearer required + if (configuration && configuration.apiKey) { + const localVarApiKeyValue = typeof configuration.apiKey === 'function' + ? configuration.apiKey("Authorization") + : configuration.apiKey; + localVarHeaderParameter["Authorization"] = localVarApiKeyValue; + } + + localVarUrlObj.query = Object.assign({}, localVarUrlObj.query, localVarQueryParameter, options.query); + // fix override query string Detail: https://stackoverflow.com/a/7517673/1077943 + localVarUrlObj.search = null; + localVarRequestOptions.headers = Object.assign({}, localVarHeaderParameter, options.headers); + + return { + url: url.format(localVarUrlObj), + options: localVarRequestOptions, + }; + }, /** * * @param {number} courseId @@ -4256,6 +4311,24 @@ export const CourseGroupsApiFp = function(configuration?: Configuration) { }); }; }, + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseGroupsGetAllCourseGroupsWithNames(courseId: number, options?: any): (fetch?: FetchAPI, basePath?: string) => Promise> { + const localVarFetchArgs = CourseGroupsApiFetchParamCreator(configuration).courseGroupsGetAllCourseGroupsWithNames(courseId, options); + return (fetch: FetchAPI = isomorphicFetch, basePath: string = BASE_PATH) => { + return fetch(basePath + localVarFetchArgs.url, localVarFetchArgs.options).then((response) => { + if (response.status >= 200 && response.status < 300) { + return response.json(); + } else { + throw response; + } + }); + }; + }, /** * * @param {number} courseId @@ -4399,6 +4472,15 @@ export const CourseGroupsApiFactory = function (configuration?: Configuration, f courseGroupsGetAllCourseGroups(courseId: number, options?: any) { return CourseGroupsApiFp(configuration).courseGroupsGetAllCourseGroups(courseId, options)(fetch, basePath); }, + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + courseGroupsGetAllCourseGroupsWithNames(courseId: number, options?: any) { + return CourseGroupsApiFp(configuration).courseGroupsGetAllCourseGroupsWithNames(courseId, options)(fetch, basePath); + }, /** * * @param {number} courseId @@ -4506,6 +4588,17 @@ export class CourseGroupsApi extends BaseAPI { return CourseGroupsApiFp(this.configuration).courseGroupsGetAllCourseGroups(courseId, options)(this.fetch, this.basePath); } + /** + * + * @param {number} courseId + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof CourseGroupsApi + */ + public courseGroupsGetAllCourseGroupsWithNames(courseId: number, options?: any) { + return CourseGroupsApiFp(this.configuration).courseGroupsGetAllCourseGroupsWithNames(courseId, options)(this.fetch, this.basePath); + } + /** * * @param {number} courseId From 8cb61aaf87828d7ce23b5885aed7611fa40dabde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Wed, 4 Mar 2026 22:09:52 +0300 Subject: [PATCH 03/39] feat: add course groups api to apisingleton --- hwproj.front/src/api/ApiSingleton.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/hwproj.front/src/api/ApiSingleton.ts b/hwproj.front/src/api/ApiSingleton.ts index df3527ba0..1886ef4ce 100644 --- a/hwproj.front/src/api/ApiSingleton.ts +++ b/hwproj.front/src/api/ApiSingleton.ts @@ -8,7 +8,8 @@ import { TasksApi, StatisticsApi, SystemApi, - FilesApi + FilesApi, + CourseGroupsApi } from "."; import AuthService from "../services/AuthService"; import CustomFilesApi from "./CustomFilesApi"; @@ -18,6 +19,7 @@ class Api { readonly accountApi: AccountApi; readonly expertsApi: ExpertsApi; readonly coursesApi: CoursesApi; + readonly courseGroupsApi: CourseGroupsApi; readonly solutionsApi: SolutionsApi; readonly notificationsApi: NotificationsApi; readonly homeworksApi: HomeworksApi; @@ -32,6 +34,7 @@ class Api { accountApi: AccountApi, expertsApi: ExpertsApi, coursesApi: CoursesApi, + courseGroupsApi: CourseGroupsApi, solutionsApi: SolutionsApi, notificationsApi: NotificationsApi, homeworksApi: HomeworksApi, @@ -45,6 +48,7 @@ class Api { this.accountApi = accountApi; this.expertsApi = expertsApi; this.coursesApi = coursesApi; + this.courseGroupsApi = courseGroupsApi; this.solutionsApi = solutionsApi; this.notificationsApi = notificationsApi; this.homeworksApi = homeworksApi; @@ -78,6 +82,7 @@ ApiSingleton = new Api( new AccountApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new ExpertsApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new CoursesApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), + new CourseGroupsApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new SolutionsApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new NotificationsApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), new HomeworksApi({basePath: basePath, apiKey: () => "Bearer " + authService.getToken()!}), From 446ed7a5c1628bd38869759a5981811e5ac77242 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Wed, 4 Mar 2026 22:11:33 +0300 Subject: [PATCH 04/39] feat: add course group tab prototype --- .../src/components/Courses/Course.tsx | 5 +- .../src/components/Courses/CourseGroups.tsx | 248 +++++++++++++++++- 2 files changed, 247 insertions(+), 6 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 0915f4eeb..a753459d8 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -399,7 +399,10 @@ const Course: React.FC = () => { /> } {tabValue === "groups" && isCourseMentor && - + } diff --git a/hwproj.front/src/components/Courses/CourseGroups.tsx b/hwproj.front/src/components/Courses/CourseGroups.tsx index 75af2dc49..67f5b04ad 100644 --- a/hwproj.front/src/components/Courses/CourseGroups.tsx +++ b/hwproj.front/src/components/Courses/CourseGroups.tsx @@ -1,10 +1,248 @@ -import {FC} from "react"; -import {Card, CardContent, CardActions, Grid, Button, Typography, Alert, AlertTitle} from '@mui/material'; +import {FC, useEffect, useState} from "react"; +import { + Card, + CardContent, + Grid, + Button, + Typography, + Alert, + AlertTitle, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + TextField, + Autocomplete, + Stack +} from "@mui/material"; +import {AccountDataDto, CourseGroupsApi, GroupViewModel, Configuration, Group} from "@/api"; +import ApiSingleton from "../../api/ApiSingleton"; -interface ICourseGroupsProps {} +interface ICourseGroupsProps { + courseId: number; + students: AccountDataDto[]; +} -const CourseGroups: FC = (props) => { - return +interface ICreateGroupFormState { + name: string; + memberIds: string[]; } +const CourseGroups: FC = (props) => { + const {courseId, students} = props; + + const [groups, setGroups] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isError, setIsError] = useState(false); + + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [formState, setFormState] = useState({ + name: "", + memberIds: [] + }); + const [isSubmitting, setIsSubmitting] = useState(false); + + const loadGroups = async () => { + setIsLoading(true); + setIsError(false); + try { + const result = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(courseId); + setGroups(result); + } catch { + setIsError(true); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + loadGroups(); + }, [courseId]); + + const handleOpenDialog = () => { + setFormState({ + name: "", + memberIds: [] + }); + setIsDialogOpen(true); + }; + + const handleCloseDialog = () => { + if (isSubmitting) return; + setIsDialogOpen(false); + }; + + const handleSubmit = async () => { + if (!formState.name.trim() || formState.memberIds.length === 0) { + return; + } + + setIsSubmitting(true); + try { + await ApiSingleton.courseGroupsApi.courseGroupsCreateCourseGroup(courseId, { + name: formState.name.trim(), + groupMatesIds: formState.memberIds, + courseId: courseId + }); + setIsDialogOpen(false); + await loadGroups(); + } finally { + setIsSubmitting(false); + } + }; + + const getStudentName = (userId: string) => { + const student = students.find(s => s.userId === userId); + if (!student) { + return userId; + } + const nameParts = [student.surname, student.name, student.middleName].filter(Boolean); + return `${nameParts.join(" ") || student.email}`; + }; + + const namedGroups = groups.filter(g => g.name && g.name.trim().length > 0); + + return ( + + + + + Группы курса + + + + + + {isError && + + + Не удалось загрузить группы + Попробуйте обновить страницу позже. + + + } + + {!isLoading && namedGroups.length === 0 && !isError && + + + Пока нет ни одной именованной группы. + + + } + + + {namedGroups.map(group => { + const name = group.name!; + const studentsIds = group.studentsIds || []; + + return ( + + + + + {name} + + {studentsIds.length > 0 ? ( + + {studentsIds.map(id => ( + + {getStudentName(id)} + + ))} + + ) : ( + + В группе пока нет участников. + + )} + + + + ); + })} + + + + + Создать новую группу + + + + + { + e.persist(); + setFormState(prev => ({ + ...prev, + name: e.target.value + })); + }} + /> + + + formState.memberIds.includes(s.userId!))} + getOptionLabel={(option) => + `${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim() + } + filterSelectedOptions + onChange={(e, values) => { + e.persist(); + setFormState(prev => ({ + ...prev, + memberIds: values + .map(x => x.userId!) + .filter(Boolean) + })); + }} + renderInput={(params) => ( + + )} + /> + + + + + + + + + + ); +}; + export default CourseGroups; From d54c04585a3a029c86fdc300ca06f5c6d46071e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Fri, 6 Mar 2026 11:46:54 +0300 Subject: [PATCH 05/39] fix: groups ui --- .../src/components/Courses/Course.tsx | 7 ++--- .../src/components/Courses/CourseGroups.tsx | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index a753459d8..972942cf7 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -328,11 +328,8 @@ const Course: React.FC = () => { label={newStudents.length}/> }/>} {isCourseMentor && -
Группы
- - }/>} +
Группы
}/> + } {tabValue === "homeworks" && = (props) => { const namedGroups = groups.filter(g => g.name && g.name.trim().length > 0); return ( - + @@ -131,27 +133,29 @@ const CourseGroups: FC = (props) => { {!isLoading && namedGroups.length === 0 && !isError && - Пока нет ни одной именованной группы. + На курсе пока нет групп. } - + {namedGroups.map(group => { const name = group.name!; const studentsIds = group.studentsIds || []; return ( - - - - + + + }> + {name} + + {studentsIds.length > 0 ? ( - + {studentsIds.map(id => ( - + {getStudentName(id)} ))} @@ -161,8 +165,8 @@ const CourseGroups: FC = (props) => { В группе пока нет участников. )} - - + + ); })} From 3e9825434e2df529a3c070bf299bae4a3b9ec672 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Mon, 9 Mar 2026 19:29:47 +0300 Subject: [PATCH 06/39] feat: add group choose for homework --- .../ViewModels/HomeworkViewModels.cs | 4 ++ .../Domains/MappingExtensions.cs | 2 + .../Models/Homework.cs | 2 + .../Services/HomeworksService.cs | 3 +- hwproj.front/src/api/api.ts | 6 ++ .../Homeworks/CourseHomeworkExperimental.tsx | 58 ++++++++++++++++++- 6 files changed, 71 insertions(+), 4 deletions(-) diff --git a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs index 2c7b0a857..1a196e463 100644 --- a/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs +++ b/HwProj.Common/HwProj.Models/CoursesService/ViewModels/HomeworkViewModels.cs @@ -27,6 +27,8 @@ public class CreateHomeworkViewModel public List Tasks { get; set; } = new List(); public ActionOptions? ActionOptions { get; set; } + + public long? GroupId { get; set; } } public class HomeworkViewModel @@ -58,5 +60,7 @@ public class HomeworkViewModel public List Tags { get; set; } = new List(); public List Tasks { get; set; } = new List(); + + public long? GroupId { get; set; } } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs index a6c321f66..152bf0410 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/MappingExtensions.cs @@ -32,6 +32,7 @@ public static HomeworkViewModel ToHomeworkViewModel(this Homework homework) IsDeferred = DateTime.UtcNow < homework.PublicationDate, Tasks = homework.Tasks.Select(t => t.ToHomeworkTaskViewModel()).ToList(), Tags = tags.ToList(), + GroupId = homework.GroupId, }; } @@ -147,6 +148,7 @@ public static Homework ToHomework(this CreateHomeworkViewModel homework) PublicationDate = homework.PublicationDate, Tasks = homework.Tasks.Select(t => t.ToHomeworkTask()).ToList(), Tags = string.Join(";", homework.Tags), + GroupId = homework.GroupId, }; public static CourseTemplate ToCourseTemplate(this CreateCourseViewModel createCourseViewModel) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs index 455c411a8..466bacaf2 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Models/Homework.cs @@ -25,5 +25,7 @@ public class Homework : IEntity public long CourseId { get; set; } public List Tasks { get; set; } + + public long? GroupId { get; set; } } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs index 76844defc..29ba73301 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs @@ -84,7 +84,8 @@ public async Task UpdateHomeworkAsync(long homeworkId, CreateHomeworkV DeadlineDate = update.DeadlineDate, PublicationDate = update.PublicationDate, IsDeadlineStrict = update.IsDeadlineStrict, - Tags = update.Tags + Tags = update.Tags, + GroupId = update.GroupId }); var updatedHomework = await _homeworksRepository.GetWithTasksAsync(homeworkId); diff --git a/hwproj.front/src/api/api.ts b/hwproj.front/src/api/api.ts index 04a3e0fae..3c6582c04 100644 --- a/hwproj.front/src/api/api.ts +++ b/hwproj.front/src/api/api.ts @@ -1365,6 +1365,12 @@ export interface HomeworkViewModel { * @memberof HomeworkViewModel */ tasks?: Array; + /** + * + * @type {number} + * @memberof HomeworkViewModel + */ + groupId?: number; } /** * diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 512bf8872..2ff1f47cb 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -10,7 +10,8 @@ Stack, TextField, Tooltip, - Typography + Typography, + Autocomplete } from "@mui/material"; import {MarkdownEditor, MarkdownPreview} from "components/Common/MarkdownEditor"; import FilesPreviewList from "components/Files/FilesPreviewList"; @@ -18,7 +19,7 @@ import {IFileInfo} from "components/Files/IFileInfo"; import {FC, useEffect, useState} from "react" import Utils from "services/Utils"; import { - HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel + HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel, Group } from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; import Tags from "../Common/Tags"; @@ -114,6 +115,9 @@ const CourseHomeworkEditor: FC<{ const [title, setTitle] = useState(loadedHomework.title!) const [tags, setTags] = useState(loadedHomework.tags!) const [description, setDescription] = useState(loadedHomework.description!) + const [selectedGroupId, setSelectedGroupId] = useState(loadedHomework.groupId) + const [groups, setGroups] = useState([]) + const [groupsLoading, setGroupsLoading] = useState(false) const [hasErrors, setHasErrors] = useState(false) @@ -124,6 +128,21 @@ const CourseHomeworkEditor: FC<{ const [deadlineSuggestion, setDeadlineSuggestion] = useState(undefined) const [tagSuggestion, setTagSuggestion] = useState(undefined) + const loadGroups = async () => { + setGroupsLoading(true) + try { + const courseGroups = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(courseId) + setGroups(courseGroups) + } catch (error) { + console.error('Failed to load groups:', error) + } finally { + setGroupsLoading(false) + } + } + useEffect(() => { + loadGroups() + }, [courseId]) + useEffect(() => { if (!isNewHomework || !metadata.publicationDate) return const isTest = tags.includes(TestTag) @@ -164,13 +183,14 @@ const CourseHomeworkEditor: FC<{ title: title, description: description, tags: tags, + groupId: selectedGroupId, hasErrors: hasErrors, deadlineDateNotSet: metadata.hasDeadline && !metadata.deadlineDate, isModified: true, } props.onUpdate({homework: update}) - }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo]) + }, [title, description, tags, metadata, hasErrors, filesState.selectedFilesInfo, selectedGroupId]) useEffect(() => { setHasErrors(!title || metadata.hasErrors) @@ -228,6 +248,7 @@ const CourseHomeworkEditor: FC<{ deadlineDate: metadata.deadlineDate, isDeadlineStrict: metadata.isDeadlineStrict, publicationDate: metadata.publicationDate, + groupId: selectedGroupId, actionOptions: editOptions, tasks: isNewHomework ? homework.tasks!.map(t => { const task: PostTaskViewModel = { @@ -287,6 +308,37 @@ const CourseHomeworkEditor: FC<{ + + {!isNewHomework && isPublished ? ( + g.id === loadedHomework.groupId)?.name || "Все студенты"} + variant="outlined" + fullWidth + disabled + /> + ) : ( + option.name || ""} + value={selectedGroupId !== undefined + ? groups.find(g => g.id === selectedGroupId) || null + : { id: undefined, name: "Все студенты" }} + onChange={(event, newValue) => { + setSelectedGroupId(newValue?.id) + }} + loading={groupsLoading} + renderInput={(params) => ( + + )} + /> + )} + {tags.includes(TestTag) && From cb7da7ab5aeed534285e1bf4c8573bbbead835d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Tue, 10 Mar 2026 20:33:27 +0300 Subject: [PATCH 07/39] refactor: separate group selector --- .../src/components/Common/GroupSelector.tsx | 70 +++++++++++++++++++ .../Homeworks/CourseHomeworkExperimental.tsx | 58 +++------------ 2 files changed, 78 insertions(+), 50 deletions(-) create mode 100644 hwproj.front/src/components/Common/GroupSelector.tsx diff --git a/hwproj.front/src/components/Common/GroupSelector.tsx b/hwproj.front/src/components/Common/GroupSelector.tsx new file mode 100644 index 000000000..6723bdef0 --- /dev/null +++ b/hwproj.front/src/components/Common/GroupSelector.tsx @@ -0,0 +1,70 @@ +import {FC, useEffect, useState} from "react"; +import { + Grid, + TextField, + Autocomplete +} from "@mui/material"; +import ApiSingleton from "../../api/ApiSingleton"; +import { Group } from "@/api"; + + +interface GroupSelectorProps { + courseId: number, + onGroupIdChange: (groupId?: number) => void + selectedGroupId?: number , + disabled?: boolean, +} + +const GroupSelector: FC = (props) => { + const [groups, setGroups] = useState([]) + const [groupsLoading, setGroupsLoading] = useState(false) + + const loadGroups = async () => { + setGroupsLoading(true) + try { + const courseGroups = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(props.courseId) + setGroups(courseGroups) + } catch (error) { + console.error('Failed to load groups:', error) + } finally { + setGroupsLoading(false) + } + } + useEffect(() => { + loadGroups() + }, [props.courseId]) + + return ( + + {props.disabled ? ( + g.id === props.selectedGroupId)?.name || "Все студенты"} + variant="outlined" + fullWidth + disabled + /> + ) : ( + option.name || ""} + value={props.selectedGroupId !== undefined + ? groups.find(g => g.id === props.selectedGroupId) || null + : { id: undefined, name: "Все студенты" }} + onChange={(_, newGroup) => props.onGroupIdChange(newGroup?.id)} + loading={groupsLoading} + renderInput={(params) => ( + + )} + /> + )} + + ) +} + +export default GroupSelector \ No newline at end of file diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 2ff1f47cb..d45976cc9 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -11,7 +11,6 @@ TextField, Tooltip, Typography, - Autocomplete } from "@mui/material"; import {MarkdownEditor, MarkdownPreview} from "components/Common/MarkdownEditor"; import FilesPreviewList from "components/Files/FilesPreviewList"; @@ -19,7 +18,7 @@ import {IFileInfo} from "components/Files/IFileInfo"; import {FC, useEffect, useState} from "react" import Utils from "services/Utils"; import { - HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel, Group + HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel } from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; import Tags from "../Common/Tags"; @@ -38,6 +37,7 @@ import Lodash from "lodash"; import {CourseUnitType} from "../Files/CourseUnitType" import ProcessFilesUtils from "../Utils/ProcessFilesUtils"; import {FilesHandler} from "@/components/Files/FilesHandler"; +import GroupSelector from "../Common/GroupSelector"; export interface HomeworkAndFilesInfo { homework: HomeworkViewModel & { isModified?: boolean }, @@ -116,8 +116,6 @@ const CourseHomeworkEditor: FC<{ const [tags, setTags] = useState(loadedHomework.tags!) const [description, setDescription] = useState(loadedHomework.description!) const [selectedGroupId, setSelectedGroupId] = useState(loadedHomework.groupId) - const [groups, setGroups] = useState([]) - const [groupsLoading, setGroupsLoading] = useState(false) const [hasErrors, setHasErrors] = useState(false) @@ -128,21 +126,6 @@ const CourseHomeworkEditor: FC<{ const [deadlineSuggestion, setDeadlineSuggestion] = useState(undefined) const [tagSuggestion, setTagSuggestion] = useState(undefined) - const loadGroups = async () => { - setGroupsLoading(true) - try { - const courseGroups = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(courseId) - setGroups(courseGroups) - } catch (error) { - console.error('Failed to load groups:', error) - } finally { - setGroupsLoading(false) - } - } - useEffect(() => { - loadGroups() - }, [courseId]) - useEffect(() => { if (!isNewHomework || !metadata.publicationDate) return const isTest = tags.includes(TestTag) @@ -308,37 +291,12 @@ const CourseHomeworkEditor: FC<{ - - {!isNewHomework && isPublished ? ( - g.id === loadedHomework.groupId)?.name || "Все студенты"} - variant="outlined" - fullWidth - disabled - /> - ) : ( - option.name || ""} - value={selectedGroupId !== undefined - ? groups.find(g => g.id === selectedGroupId) || null - : { id: undefined, name: "Все студенты" }} - onChange={(event, newValue) => { - setSelectedGroupId(newValue?.id) - }} - loading={groupsLoading} - renderInput={(params) => ( - - )} - /> - )} - + setSelectedGroupId(groupId)} + selectedGroupId={selectedGroupId} + disabled={!isNewHomework} + /> {tags.includes(TestTag) && From e02de09cc212723bce2a786390c1342a2311ffb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Fri, 13 Mar 2026 13:01:23 +0300 Subject: [PATCH 08/39] feat: add homework update group validation --- .../HwProj.CoursesService.API/Domains/Validations.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/Validations.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/Validations.cs index 2a4dda861..6cade85ea 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Domains/Validations.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Domains/Validations.cs @@ -112,6 +112,11 @@ public static List ValidateHomework(CreateHomeworkViewModel homework, Ho errors.Add("Нельзя изменить дату публикации домашнего задания, если она уже показана студента"); } + if (previousState.GroupId != homework.GroupId) + { + errors.Add("Нельзя изменить группу для домашнего задания, если оно уже опубликовано"); + } + return errors; } } From 707aaf3975fd32cd3e101b9dfca2a008f062cbe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Fri, 13 Mar 2026 13:05:33 +0300 Subject: [PATCH 09/39] feat: add apply filter subtractively method --- .../Services/CourseFilterService.cs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index b47139ee0..8bc18ab20 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -1,11 +1,13 @@ using System.Linq; using System.Threading.Tasks; +using System.Collections.Generic; using HwProj.CoursesService.API.Models; using HwProj.CoursesService.API.Repositories; using HwProj.Models.CoursesService; using HwProj.Models.CoursesService.DTO; using HwProj.Models.CoursesService.ViewModels; using HwProj.Models.Result; +using System; namespace HwProj.CoursesService.API.Services { @@ -170,5 +172,53 @@ private CourseDTO ApplyFilterInternal(CourseDTO courseDto, CourseFilter? courseF : courseDto.Homeworks }; } + + private CourseDTO ApplyFilterSubtractive(CourseDTO courseDto, CourseFilter? courseFilter) + { + var filter = courseFilter?.Filter; + + if (filter == null) + { + return courseDto; + } + + return new CourseDTO + { + Id = courseDto.Id, + Name = courseDto.Name, + GroupName = courseDto.GroupName, + IsCompleted = courseDto.IsCompleted, + IsOpen = courseDto.IsOpen, + InviteCode = courseDto.InviteCode, + Groups = + (filter.StudentIds.Any() + ? courseDto.Groups.Select(gs => + { + var filteredStudentsIds = gs.StudentsIds.Except(filter.StudentIds).ToArray(); + return filteredStudentsIds.Any() + ? new GroupViewModel + { + Id = gs.Id, + StudentsIds = filteredStudentsIds + } + : null; + }) + .Where(t => t != null) + .ToArray() + : courseDto.Groups)!, + MentorIds = filter.MentorIds.Any() + ? courseDto.MentorIds.Except(filter.MentorIds).ToArray() + : courseDto.MentorIds, + CourseMates = + filter.StudentIds.Any() + ? courseDto.CourseMates + .Where(mate => !mate.IsAccepted || !filter.StudentIds.Contains(mate.StudentId)).ToArray() + : courseDto.CourseMates, + Homeworks = + filter.HomeworkIds.Any() + ? courseDto.Homeworks.Where(hw => !filter.HomeworkIds.Contains(hw.Id)).ToArray() + : courseDto.Homeworks + }; + } } } From 623c2740847b7303f2ad5b4c699e0d211d17237a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Fri, 13 Mar 2026 13:06:16 +0300 Subject: [PATCH 10/39] feat: add global filter subtraction applying --- .../Services/CourseFilterService.cs | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index 8bc18ab20..80f41ba50 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -90,6 +90,32 @@ public async Task ApplyFilter(CourseDTO courseDto, string userId) : courseDto; if (isMentor || !isCourseStudent) return course; + // Применение глобального фильтра для вычитания групповых домашних заданий + if (!isMentor) + { + var groupFilter = await _courseFilterRepository.GetAsync("", courseDto.Id); + if (groupFilter != null) + { + // Если домашнее задание у пользователя в персональном фильтре, то не вычитаем его + var userHomeworkIds = userFilter?.Filter.HomeworkIds ?? new List(); + var homeworksToRemove = groupFilter.Filter.HomeworkIds.Except(userHomeworkIds).ToList(); + + if (homeworksToRemove.Any()) + { + var filterToRemove = new CourseFilter + { + Filter = new Filter + { + HomeworkIds = homeworksToRemove, + StudentIds = new List(), + MentorIds = new List() + } + }; + course = ApplyFilterSubtractive(course, filterToRemove); + } + } + } + var mentorIds = course.MentorIds .Where(u => // Фильтрация не настроена вообще From 1d255abfbbd3e0bccb55925a6913a1c2b3cda1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Fri, 13 Mar 2026 13:12:35 +0300 Subject: [PATCH 11/39] feat: add filter updating for group homeworks method --- .../Services/HomeworksService.cs | 79 ++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs index 29ba73301..13059a571 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs @@ -8,6 +8,9 @@ using HwProj.Models; using HwProj.Models.CoursesService.ViewModels; using HwProj.NotificationService.Events.CoursesService; +using HwProj.CoursesService.API.Repositories.Groups; +using Microsoft.EntityFrameworkCore; +using System.Collections.Generic; namespace HwProj.CoursesService.API.Services { @@ -16,13 +19,17 @@ public class HomeworksService : IHomeworksService private readonly IHomeworksRepository _homeworksRepository; private readonly IEventBus _eventBus; private readonly ICoursesRepository _coursesRepository; + private readonly IGroupMatesRepository _groupMatesRepository; + private readonly ICourseFilterRepository _courseFilterRepository; - public HomeworksService(IHomeworksRepository homeworksRepository, IEventBus eventBus, - ICoursesRepository coursesRepository) + public HomeworksService(IHomeworksRepository homeworksRepository, IEventBus eventBus, ICoursesRepository coursesRepository, + IGroupMatesRepository groupMatesRepository, IGroupsService groupsService, ICourseFilterRepository courseFilterRepository) { _homeworksRepository = homeworksRepository; _eventBus = eventBus; _coursesRepository = coursesRepository; + _groupMatesRepository = groupMatesRepository; + _courseFilterRepository = courseFilterRepository; } public async Task AddHomeworkAsync(long courseId, CreateHomeworkViewModel homeworkViewModel) @@ -92,5 +99,73 @@ public async Task UpdateHomeworkAsync(long homeworkId, CreateHomeworkV CourseDomain.FillTasksInHomework(updatedHomework); return updatedHomework; } + + private async Task UpdateGroupFilters(long courseId, long homeworkId, List groupMates) + { + // Добавление группового домашнего задания в глобальный фильтр курса + var globalFilter = await _courseFilterRepository.GetAsync("", courseId); + + if (globalFilter != null) + { + var filter = globalFilter.Filter; + if (!filter.HomeworkIds.Contains(homeworkId)) + { + filter.HomeworkIds.Add(homeworkId); + } + + await _courseFilterRepository.UpdateAsync(globalFilter.Id, f => + new CourseFilter + { + FilterJson = new CourseFilter { Filter = filter }.FilterJson + }); + } + else + { + var newFilter = new Filter + { + StudentIds = new List(), + HomeworkIds = new List { homeworkId }, + MentorIds = new List(), + }; + + await _courseFilterRepository.AddAsync(new CourseFilter { Filter = newFilter }, "", courseId); + } + + // Добавление группового домашнего задания в персональные фильтры участников группы + foreach (var groupMate in groupMates) + { + var studentFilter = await _courseFilterRepository.GetAsync(groupMate.StudentId, courseId); + + if (studentFilter != null) + { + var filter = studentFilter.Filter; + if (!filter.HomeworkIds.Contains(homeworkId)) + { + filter.HomeworkIds.Add(homeworkId); + } + + await _courseFilterRepository.UpdateAsync(studentFilter.Id, f => + new CourseFilter + { + FilterJson = new CourseFilter { Filter = filter }.FilterJson + }); + } + else + { + var newFilter = new Filter + { + StudentIds = new List(), + HomeworkIds = new List { homeworkId }, + MentorIds = new List() + }; + + await _courseFilterRepository.AddAsync( + new CourseFilter { Filter = newFilter }, + groupMate.StudentId, + courseId + ); + } + } + } } } From 7850406586391f887b9105808294007119c94c07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Fri, 13 Mar 2026 13:13:39 +0300 Subject: [PATCH 12/39] feat: add updating filters for group homeworks --- .../Services/HomeworksService.cs | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs index 13059a571..4efb7be19 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs @@ -39,14 +39,23 @@ public async Task AddHomeworkAsync(long courseId, CreateHomeworkViewMo homework.CourseId = courseId; var course = await _coursesRepository.GetWithCourseMatesAndHomeworksAsync(courseId); - var studentIds = course.CourseMates.Where(cm => cm.IsAccepted).Select(cm => cm.StudentId).ToArray(); + var notificationStudentIds = course.CourseMates.Where(cm => cm.IsAccepted).Select(cm => cm.StudentId).ToArray(); + + await _homeworksRepository.AddAsync(homework); + + if(homework.GroupId != null) + { + var groupMates = await _groupMatesRepository.FindAll(gm => gm.GroupId == homework.GroupId).ToListAsync(); + await UpdateGroupFilters(courseId, homework.Id, groupMates); + notificationStudentIds = groupMates.Select(gm => gm.StudentId).ToArray(); + } + if (DateTime.UtcNow >= homework.PublicationDate) { - _eventBus.Publish(new NewHomeworkEvent(homework.Title, course.Name, course.Id, studentIds, + _eventBus.Publish(new NewHomeworkEvent(homework.Title, course.Name, course.Id, notificationStudentIds, homework.DeadlineDate)); } - await _homeworksRepository.AddAsync(homework); return await GetHomeworkAsync(homework.Id, withCriteria: true); } @@ -81,7 +90,17 @@ public async Task UpdateHomeworkAsync(long homeworkId, CreateHomeworkV var studentIds = course!.CourseMates.Where(cm => cm.IsAccepted).Select(cm => cm.StudentId).ToArray(); if (options.SendNotification && update.PublicationDate <= DateTime.UtcNow) - _eventBus.Publish(new UpdateHomeworkEvent(update.Title, course.Id, course.Name, studentIds)); + { + var notificationStudentIds = studentIds; + + if (update.GroupId != null) + { + var groupMates = await _groupMatesRepository.FindAll(gm => gm.GroupId == update.GroupId).ToListAsync(); + notificationStudentIds = groupMates.Select(gm => gm.StudentId).ToArray(); + } + + _eventBus.Publish(new UpdateHomeworkEvent(update.Title, course.Id, course.Name, notificationStudentIds)); + } await _homeworksRepository.UpdateAsync(homeworkId, hw => new Homework() { From 501c0025af411872b97b27cb996a7e394d931cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 22 Mar 2026 16:50:08 +0300 Subject: [PATCH 13/39] fix: student filters applying --- .../Services/CourseFilterService.cs | 89 +++++++++++++------ .../Services/HomeworksService.cs | 2 +- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index 80f41ba50..a09426c5f 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -1,6 +1,5 @@ using System.Linq; using System.Threading.Tasks; -using System.Collections.Generic; using HwProj.CoursesService.API.Models; using HwProj.CoursesService.API.Repositories; using HwProj.Models.CoursesService; @@ -8,17 +7,21 @@ using HwProj.Models.CoursesService.ViewModels; using HwProj.Models.Result; using System; +using HwProj.CoursesService.API.Domains; namespace HwProj.CoursesService.API.Services { public class CourseFilterService : ICourseFilterService { private readonly ICourseFilterRepository _courseFilterRepository; + private readonly IHomeworksService _homeworksService; public CourseFilterService( - ICourseFilterRepository courseFilterRepository) + ICourseFilterRepository courseFilterRepository, + IHomeworksService homeworksService) { _courseFilterRepository = courseFilterRepository; + _homeworksService = homeworksService; } public async Task> CreateOrUpdateCourseFilter(CreateCourseFilterModel courseFilterModel) @@ -85,37 +88,25 @@ public async Task ApplyFilter(CourseDTO courseDto, string userId) (await _courseFilterRepository.GetAsync(findFiltersFor, courseDto.Id)) .ToDictionary(x => x.UserId, x => x.CourseFilter); - var course = courseFilters.TryGetValue(userId, out var userFilter) - ? ApplyFilterInternal(courseDto, userFilter) - : courseDto; - if (isMentor || !isCourseStudent) return course; - // Применение глобального фильтра для вычитания групповых домашних заданий - if (!isMentor) + if (isCourseStudent) { + var studentCourse = courseDto; var groupFilter = await _courseFilterRepository.GetAsync("", courseDto.Id); if (groupFilter != null) { - // Если домашнее задание у пользователя в персональном фильтре, то не вычитаем его - var userHomeworkIds = userFilter?.Filter.HomeworkIds ?? new List(); - var homeworksToRemove = groupFilter.Filter.HomeworkIds.Except(userHomeworkIds).ToList(); - - if (homeworksToRemove.Any()) - { - var filterToRemove = new CourseFilter - { - Filter = new Filter - { - HomeworkIds = homeworksToRemove, - StudentIds = new List(), - MentorIds = new List() - } - }; - course = ApplyFilterSubtractive(course, filterToRemove); - } + studentCourse = ApplyFilterSubtractive(courseDto, groupFilter); } + return courseFilters.TryGetValue(userId, out var studentFilter) + ? await ApplyFilterAdditive(studentCourse, studentFilter) + : studentCourse; } + var course = courseFilters.TryGetValue(userId, out var userFilter) + ? ApplyFilterInternal(courseDto, userFilter) + : courseDto; + if (isMentor || !isCourseStudent) return course; + var mentorIds = course.MentorIds .Where(u => // Фильтрация не настроена вообще @@ -246,5 +237,53 @@ private CourseDTO ApplyFilterSubtractive(CourseDTO courseDto, CourseFilter? cour : courseDto.Homeworks }; } + + private async Task ApplyFilterAdditive(CourseDTO courseDto, CourseFilter? courseFilter) + { + var filter = courseFilter?.Filter; + + if (filter == null) + { + return courseDto; + } + + var additionalHomeworks = filter.HomeworkIds.Any() + ? (await Task.WhenAll(filter.HomeworkIds.Select(id => _homeworksService.GetHomeworkAsync(id)))) + .Where(hw => hw != null) + .Select(hw => hw.ToHomeworkViewModel()) + .ToArray() + : Array.Empty(); + + return new CourseDTO + { + Id = courseDto.Id, + Name = courseDto.Name, + GroupName = courseDto.GroupName, + IsCompleted = courseDto.IsCompleted, + IsOpen = courseDto.IsOpen, + InviteCode = courseDto.InviteCode, + Groups = + (filter.StudentIds.Any() + ? courseDto.Groups.Select(gs => + { + var filteredStudentsIds = gs.StudentsIds.Union(filter.StudentIds).ToArray(); + return filteredStudentsIds.Any() + ? new GroupViewModel + { + Id = gs.Id, + StudentsIds = filteredStudentsIds + } + : null; + }) + .Where(t => t != null) + .ToArray() + : courseDto.Groups)!, + MentorIds = filter.MentorIds.Any() + ? courseDto.MentorIds.Union(filter.MentorIds).ToArray() + : courseDto.MentorIds, + CourseMates = courseDto.CourseMates, + Homeworks = courseDto.Homeworks.Union(additionalHomeworks).ToArray() + }; + } } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs index 4efb7be19..d17eacba2 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs @@ -76,7 +76,7 @@ public async Task GetForEditingHomeworkAsync(long homeworkId) public async Task DeleteHomeworkAsync(long homeworkId) { - await _homeworksRepository.DeleteAsync(homeworkId); + await _homeworksRepository.DeleteAsync(homeworkId); //TODO: удалить из фильтров } public async Task UpdateHomeworkAsync(long homeworkId, CreateHomeworkViewModel homeworkViewModel) From 85acdbf6c1e525fa1350298038ef621ba56e1402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Mon, 23 Mar 2026 19:51:28 +0300 Subject: [PATCH 14/39] fix: deleting homework --- .../Services/CourseFilterService.cs | 3 +- .../Services/HomeworksService.cs | 29 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index a09426c5f..7828bedd9 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -88,11 +88,10 @@ public async Task ApplyFilter(CourseDTO courseDto, string userId) (await _courseFilterRepository.GetAsync(findFiltersFor, courseDto.Id)) .ToDictionary(x => x.UserId, x => x.CourseFilter); - // Применение глобального фильтра для вычитания групповых домашних заданий if (isCourseStudent) { var studentCourse = courseDto; - var groupFilter = await _courseFilterRepository.GetAsync("", courseDto.Id); + var groupFilter = await _courseFilterRepository.GetAsync("", courseDto.Id); // Глобальный фильтр для вычитания групповых домашних заданий if (groupFilter != null) { studentCourse = ApplyFilterSubtractive(courseDto, groupFilter); diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs index d17eacba2..abf1ba2ac 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs @@ -76,7 +76,34 @@ public async Task GetForEditingHomeworkAsync(long homeworkId) public async Task DeleteHomeworkAsync(long homeworkId) { - await _homeworksRepository.DeleteAsync(homeworkId); //TODO: удалить из фильтров + var homework = await _homeworksRepository.GetAsync(homeworkId); + if (homework == null) return; + + var course = await _coursesRepository.GetWithCourseMates(homework.CourseId); + if (course == null) return; + + var courseUserIds = course.CourseMates.Select(cm => cm.StudentId).ToList(); + courseUserIds.Add(course.MentorIds); + courseUserIds.Add(""); + + // Удаляем homeworkId из фильтров всех участников курса + foreach (var userId in courseUserIds.Distinct()) + { + var userFilter = await _courseFilterRepository.GetAsync(userId, homework.CourseId); + + if (userFilter != null && userFilter.Filter.HomeworkIds.Contains(homeworkId)) + { + userFilter.Filter.HomeworkIds.Remove(homeworkId); + + await _courseFilterRepository.UpdateAsync(userFilter.Id, f => + new CourseFilter + { + FilterJson = new CourseFilter { Filter = userFilter.Filter }.FilterJson + }); + } + } + + await _homeworksRepository.DeleteAsync(homeworkId); } public async Task UpdateHomeworkAsync(long homeworkId, CreateHomeworkViewModel homeworkViewModel) From 2a994942548116003aa19e75bda8c0a3e3a1c7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Tue, 24 Mar 2026 22:26:26 +0300 Subject: [PATCH 15/39] feat: show non included in groups course students count --- .../src/components/Courses/Course.tsx | 47 +++++++++++++++++-- .../src/components/Courses/CourseGroups.tsx | 37 ++++----------- 2 files changed, 54 insertions(+), 30 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 972942cf7..5604b92a1 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -1,7 +1,7 @@ import * as React from "react"; -import {FC, useEffect, useState} from "react"; +import {FC, useEffect, useState, useMemo} from "react"; import {useNavigate, useParams, useSearchParams} from "react-router-dom"; -import {AccountDataDto, CourseViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; +import {AccountDataDto, CourseViewModel, Group, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; import StudentStats from "./StudentStats"; import NewCourseStudents from "./NewCourseStudents"; import ApiSingleton from "../../api/ApiSingleton"; @@ -35,6 +35,7 @@ import {DotLottieReact} from "@lottiefiles/dotlottie-react"; import {FilesUploadWaiter} from "@/components/Files/FilesUploadWaiter"; import {CourseUnitType} from "@/components/Files/CourseUnitType"; import CourseGroups from "./CourseGroups"; +import { group } from "@uiw/react-md-editor"; type TabValue = "homeworks" | "stats" | "applications" | "groups" @@ -171,6 +172,35 @@ const Course: React.FC = () => { const [lecturerStatsState, setLecturerStatsState] = useState(false); + const [groups, setGroups] = useState([]); + const [groupLoadingError, setGroupLoadingError] = useState(false); + + const studentsInGroups = useMemo(() => { + const studentIds = new Set(); + groups.forEach(g => { + g.studentsIds?.forEach(id => studentIds.add(id)); + }); + return studentIds; + }, [groups]); + + const studentsWithoutGroup = useMemo(() => { + return acceptedStudents.filter(s => !studentsInGroups.has(s.userId!)); + }, [acceptedStudents, studentsInGroups]); + + const loadGroups = async () => { + setGroupLoadingError(false); + try { + const result = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(+courseId!); + setGroups(result.filter(g => g.name && g.name.trim().length > 0)); + } catch { + setGroupLoadingError(true); + } + }; + + useEffect(() => { + loadGroups(); + }, [courseId]); + const CourseMenu: FC = () => { const [anchorEl, setAnchorEl] = React.useState(null); const open = Boolean(anchorEl); @@ -299,6 +329,11 @@ const Course: React.FC = () => { } + {isCourseMentor && groups.length > 0 && studentsWithoutGroup.length > 0 && !groupLoadingError && + + Студентов, не записанных в группу: {studentsWithoutGroup.length} + + } { label={newStudents.length}/> }/>} {isCourseMentor && Группы}/> + +
Группы
+ +
}/> } {tabValue === "homeworks" && { }
diff --git a/hwproj.front/src/components/Courses/CourseGroups.tsx b/hwproj.front/src/components/Courses/CourseGroups.tsx index 423091fd2..03658ee21 100644 --- a/hwproj.front/src/components/Courses/CourseGroups.tsx +++ b/hwproj.front/src/components/Courses/CourseGroups.tsx @@ -1,4 +1,4 @@ -import {FC, useEffect, useState} from "react"; +import {FC, useState} from "react"; import { Accordion, AccordionSummary, @@ -17,12 +17,14 @@ import { Stack } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import {AccountDataDto, CourseGroupsApi, GroupViewModel, Configuration, Group} from "@/api"; +import {AccountDataDto, Group} from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; interface ICourseGroupsProps { courseId: number; students: AccountDataDto[]; + groups: Group[]; + onGroupsUpdate: () => Promise; } interface ICreateGroupFormState { @@ -31,10 +33,8 @@ interface ICreateGroupFormState { } const CourseGroups: FC = (props) => { - const {courseId, students} = props; + const {courseId, students, groups} = props; - const [groups, setGroups] = useState([]); - const [isLoading, setIsLoading] = useState(false); const [isError, setIsError] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -44,23 +44,6 @@ const CourseGroups: FC = (props) => { }); const [isSubmitting, setIsSubmitting] = useState(false); - const loadGroups = async () => { - setIsLoading(true); - setIsError(false); - try { - const result = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(courseId); - setGroups(result); - } catch { - setIsError(true); - } finally { - setIsLoading(false); - } - }; - - useEffect(() => { - loadGroups(); - }, [courseId]); - const handleOpenDialog = () => { setFormState({ name: "", @@ -87,7 +70,9 @@ const CourseGroups: FC = (props) => { courseId: courseId }); setIsDialogOpen(false); - await loadGroups(); + await props.onGroupsUpdate(); + } catch { + setIsError(true); } finally { setIsSubmitting(false); } @@ -102,8 +87,6 @@ const CourseGroups: FC = (props) => { return `${nameParts.join(" ") || student.email}`; }; - const namedGroups = groups.filter(g => g.name && g.name.trim().length > 0); - return ( @@ -130,7 +113,7 @@ const CourseGroups: FC = (props) => { } - {!isLoading && namedGroups.length === 0 && !isError && + {!isSubmitting && groups.length === 0 && !isError && На курсе пока нет групп. @@ -139,7 +122,7 @@ const CourseGroups: FC = (props) => { } - {namedGroups.map(group => { + {groups.map(group => { const name = group.name!; const studentsIds = group.studentsIds || []; From ae06821927b361c4136c8bf424b8dfd8892e205d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Thu, 26 Mar 2026 20:48:27 +0300 Subject: [PATCH 16/39] refactor: union apply filter methods by creating apply filter type --- .../Services/CourseFilterService.cs | 146 ++++-------------- 1 file changed, 34 insertions(+), 112 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index 7828bedd9..77c3d7e10 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -11,6 +11,12 @@ namespace HwProj.CoursesService.API.Services { + public enum ApplyFilterType + { + Intersect, + Union, + Subtract + } public class CourseFilterService : ICourseFilterService { private readonly ICourseFilterRepository _courseFilterRepository; @@ -66,14 +72,7 @@ public async Task ApplyFiltersToCourses(string userId, CourseDTO[] var filters = (await _courseFilterRepository.GetAsync(userId, courseIds)) .ToDictionary(x => x.CourseId, x => x.CourseFilter); - - return courses - .Select(course => - { - filters.TryGetValue(course.Id, out var courseFilter); - return ApplyFilterInternal(course, courseFilter); - }) - .ToArray(); + return (await Task.WhenAll(courses.Select(course => ApplyFilter(course, userId)))).ToArray(); } public async Task ApplyFilter(CourseDTO courseDto, string userId) @@ -94,15 +93,15 @@ public async Task ApplyFilter(CourseDTO courseDto, string userId) var groupFilter = await _courseFilterRepository.GetAsync("", courseDto.Id); // Глобальный фильтр для вычитания групповых домашних заданий if (groupFilter != null) { - studentCourse = ApplyFilterSubtractive(courseDto, groupFilter); + studentCourse = await ApplyFilterInternal(courseDto, groupFilter, ApplyFilterType.Subtract); } return courseFilters.TryGetValue(userId, out var studentFilter) - ? await ApplyFilterAdditive(studentCourse, studentFilter) + ? await ApplyFilterInternal(studentCourse, studentFilter, ApplyFilterType.Union) : studentCourse; } var course = courseFilters.TryGetValue(userId, out var userFilter) - ? ApplyFilterInternal(courseDto, userFilter) + ? await ApplyFilterInternal(courseDto, userFilter, ApplyFilterType.Intersect) : courseDto; if (isMentor || !isCourseStudent) return course; @@ -141,7 +140,7 @@ private async Task AddCourseFilter(Filter filter, long courseId, string us return courseFilterId; } - private CourseDTO ApplyFilterInternal(CourseDTO courseDto, CourseFilter? courseFilter) + private async Task ApplyFilterInternal(CourseDTO courseDto, CourseFilter? courseFilter, ApplyFilterType filterType) { var filter = courseFilter?.Filter; @@ -150,6 +149,28 @@ private CourseDTO ApplyFilterInternal(CourseDTO courseDto, CourseFilter? courseF return courseDto; } + var homeworks = filter.HomeworkIds.Any() + ? filterType switch + { + ApplyFilterType.Intersect => courseDto.Homeworks + .Where(hw => filter.HomeworkIds.Contains(hw.Id)) + .ToArray(), + + ApplyFilterType.Subtract => courseDto.Homeworks + .Where(hw => !filter.HomeworkIds.Contains(hw.Id)) + .ToArray(), + + ApplyFilterType.Union => courseDto.Homeworks + .Union((await Task.WhenAll( + filter.HomeworkIds.Select(id => _homeworksService.GetHomeworkAsync(id)))) + .Where(hw => hw != null) + .Select(hw => hw.ToHomeworkViewModel())) + .ToArray(), + + _ => courseDto.Homeworks + } + : courseDto.Homeworks; + return new CourseDTO { Id = courseDto.Id, @@ -182,106 +203,7 @@ private CourseDTO ApplyFilterInternal(CourseDTO courseDto, CourseFilter? courseF ? courseDto.CourseMates .Where(mate => !mate.IsAccepted || filter.StudentIds.Contains(mate.StudentId)).ToArray() : courseDto.CourseMates, - Homeworks = - filter.HomeworkIds.Any() - ? courseDto.Homeworks.Where(hw => filter.HomeworkIds.Contains(hw.Id)).ToArray() - : courseDto.Homeworks - }; - } - - private CourseDTO ApplyFilterSubtractive(CourseDTO courseDto, CourseFilter? courseFilter) - { - var filter = courseFilter?.Filter; - - if (filter == null) - { - return courseDto; - } - - return new CourseDTO - { - Id = courseDto.Id, - Name = courseDto.Name, - GroupName = courseDto.GroupName, - IsCompleted = courseDto.IsCompleted, - IsOpen = courseDto.IsOpen, - InviteCode = courseDto.InviteCode, - Groups = - (filter.StudentIds.Any() - ? courseDto.Groups.Select(gs => - { - var filteredStudentsIds = gs.StudentsIds.Except(filter.StudentIds).ToArray(); - return filteredStudentsIds.Any() - ? new GroupViewModel - { - Id = gs.Id, - StudentsIds = filteredStudentsIds - } - : null; - }) - .Where(t => t != null) - .ToArray() - : courseDto.Groups)!, - MentorIds = filter.MentorIds.Any() - ? courseDto.MentorIds.Except(filter.MentorIds).ToArray() - : courseDto.MentorIds, - CourseMates = - filter.StudentIds.Any() - ? courseDto.CourseMates - .Where(mate => !mate.IsAccepted || !filter.StudentIds.Contains(mate.StudentId)).ToArray() - : courseDto.CourseMates, - Homeworks = - filter.HomeworkIds.Any() - ? courseDto.Homeworks.Where(hw => !filter.HomeworkIds.Contains(hw.Id)).ToArray() - : courseDto.Homeworks - }; - } - - private async Task ApplyFilterAdditive(CourseDTO courseDto, CourseFilter? courseFilter) - { - var filter = courseFilter?.Filter; - - if (filter == null) - { - return courseDto; - } - - var additionalHomeworks = filter.HomeworkIds.Any() - ? (await Task.WhenAll(filter.HomeworkIds.Select(id => _homeworksService.GetHomeworkAsync(id)))) - .Where(hw => hw != null) - .Select(hw => hw.ToHomeworkViewModel()) - .ToArray() - : Array.Empty(); - - return new CourseDTO - { - Id = courseDto.Id, - Name = courseDto.Name, - GroupName = courseDto.GroupName, - IsCompleted = courseDto.IsCompleted, - IsOpen = courseDto.IsOpen, - InviteCode = courseDto.InviteCode, - Groups = - (filter.StudentIds.Any() - ? courseDto.Groups.Select(gs => - { - var filteredStudentsIds = gs.StudentsIds.Union(filter.StudentIds).ToArray(); - return filteredStudentsIds.Any() - ? new GroupViewModel - { - Id = gs.Id, - StudentsIds = filteredStudentsIds - } - : null; - }) - .Where(t => t != null) - .ToArray() - : courseDto.Groups)!, - MentorIds = filter.MentorIds.Any() - ? courseDto.MentorIds.Union(filter.MentorIds).ToArray() - : courseDto.MentorIds, - CourseMates = courseDto.CourseMates, - Homeworks = courseDto.Homeworks.Union(additionalHomeworks).ToArray() + Homeworks = homeworks }; } } From cde8d1acfdb643d4b438ea9e8b9e21874786f929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Fri, 27 Mar 2026 21:08:27 +0300 Subject: [PATCH 17/39] fix: parallel dbcontext access problem --- .../Services/CourseFilterService.cs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index 77c3d7e10..270496771 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -6,7 +6,7 @@ using HwProj.Models.CoursesService.DTO; using HwProj.Models.CoursesService.ViewModels; using HwProj.Models.Result; -using System; +using System.Collections.Generic; using HwProj.CoursesService.API.Domains; namespace HwProj.CoursesService.API.Services @@ -161,10 +161,7 @@ private async Task ApplyFilterInternal(CourseDTO courseDto, CourseFil .ToArray(), ApplyFilterType.Union => courseDto.Homeworks - .Union((await Task.WhenAll( - filter.HomeworkIds.Select(id => _homeworksService.GetHomeworkAsync(id)))) - .Where(hw => hw != null) - .Select(hw => hw.ToHomeworkViewModel())) + .Union(await GetHomeworksSequentially(filter.HomeworkIds)) .ToArray(), _ => courseDto.Homeworks @@ -206,5 +203,17 @@ private async Task ApplyFilterInternal(CourseDTO courseDto, CourseFil Homeworks = homeworks }; } + + private async Task> GetHomeworksSequentially(List homeworkIds) + { + var result = new List(); + foreach (var id in homeworkIds) + { + var hw = await _homeworksService.GetHomeworkAsync(id); + if (hw != null) + result.Add(hw.ToHomeworkViewModel()); + } + return result; + } } } From c2b9a4bc77ffdf84ca036ad90097fbb838932ac3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Fri, 27 Mar 2026 21:11:19 +0300 Subject: [PATCH 18/39] feat: mark grouped students --- .../src/components/Courses/Course.tsx | 2 +- .../src/components/Courses/CourseGroups.tsx | 47 ++++++++++++++++--- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 5604b92a1..ca4da5aa1 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -437,7 +437,7 @@ const Course: React.FC = () => { {tabValue === "groups" && isCourseMentor && diff --git a/hwproj.front/src/components/Courses/CourseGroups.tsx b/hwproj.front/src/components/Courses/CourseGroups.tsx index 03658ee21..b1d4b8039 100644 --- a/hwproj.front/src/components/Courses/CourseGroups.tsx +++ b/hwproj.front/src/components/Courses/CourseGroups.tsx @@ -14,15 +14,18 @@ import { DialogActions, TextField, Autocomplete, - Stack + Stack, + Tooltip, + Box } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import CheckIcon from "@mui/icons-material/Check"; import {AccountDataDto, Group} from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; interface ICourseGroupsProps { courseId: number; - students: AccountDataDto[]; + courseStudents: AccountDataDto[]; groups: Group[]; onGroupsUpdate: () => Promise; } @@ -33,7 +36,7 @@ interface ICreateGroupFormState { } const CourseGroups: FC = (props) => { - const {courseId, students, groups} = props; + const {courseId, courseStudents, groups} = props; const [isError, setIsError] = useState(false); @@ -79,7 +82,7 @@ const CourseGroups: FC = (props) => { }; const getStudentName = (userId: string) => { - const student = students.find(s => s.userId === userId); + const student = courseStudents.find(s => s.userId === userId); if (!student) { return userId; } @@ -87,6 +90,16 @@ const CourseGroups: FC = (props) => { return `${nameParts.join(" ") || student.email}`; }; + const isStudentInGroup = (userId: string): boolean => { + return groups.some(group => group.studentsIds?.includes(userId)); + }; + + const getSortedStudents = (): AccountDataDto[] => { + const studentsInGroups = courseStudents.filter(s => isStudentInGroup(s.userId!)); + const studentsNotInGroups = courseStudents.filter(s => !isStudentInGroup(s.userId!)); + return [...studentsNotInGroups, ...studentsInGroups]; + }; + return ( @@ -184,11 +197,33 @@ const CourseGroups: FC = (props) => { formState.memberIds.includes(s.userId!))} + options={getSortedStudents()} + value={courseStudents.filter(s => formState.memberIds.includes(s.userId!))} getOptionLabel={(option) => `${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim() } + renderOption={(props, option) => { + return ( + + + + {`${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim()} + + {isStudentInGroup(option.userId!) && ( + + + + )} + + + ); + }} filterSelectedOptions onChange={(e, values) => { e.persist(); From 19257f2bc4055c706f23711df1321562ff4a136d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 29 Mar 2026 12:37:38 +0300 Subject: [PATCH 19/39] fix: add automap for groups --- .../HwProj.CoursesService.API/AutomapperProfile.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs b/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs index 845b0e146..58d5effea 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/AutomapperProfile.cs @@ -21,6 +21,10 @@ public AutomapperProfile() CreateMap(); CreateMap(); + + CreateMap().ReverseMap(); + + CreateMap().ReverseMap(); } } } \ No newline at end of file From b6e2df0207dff4c93fe2fa2f2dbf5633f13f7441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 29 Mar 2026 12:38:47 +0300 Subject: [PATCH 20/39] fix: parallel access error in groups service --- .../Services/GroupsService.cs | 61 ++++++++++++++----- 1 file changed, 45 insertions(+), 16 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs index 37b1173d8..3656845aa 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs @@ -1,4 +1,6 @@ -using System.Linq; +using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using AutoMapper; using HwProj.CoursesService.API.Models; @@ -28,7 +30,10 @@ public GroupsService(IGroupsRepository groupsRepository, public async Task GetAllAsync(long courseId) { - return await _groupsRepository.GetGroupsWithGroupMatesByCourse(courseId).ToArrayAsync().ConfigureAwait(false); + return await _groupsRepository.GetGroupsWithGroupMatesByCourse(courseId) + .AsNoTracking() + .ToArrayAsync() + .ConfigureAwait(false); } public async Task GetGroupsAsync(params long[] groupIds) @@ -63,19 +68,39 @@ public async Task DeleteGroupAsync(long groupId) public async Task UpdateAsync(long groupId, Group updated) { - var group = await _groupsRepository.GetAsync(groupId); - group.GroupMates.RemoveAll(cm => true); - group.Tasks.RemoveAll(cm => true); + var group = (await _groupsRepository.GetGroupsWithGroupMatesAsync(new[] { groupId })) + .FirstOrDefault() ?? throw new InvalidOperationException($"Group with id {groupId} not found"); + + foreach (var groupMate in group.GroupMates.ToList()) + { + await _groupMatesRepository.DeleteAsync(groupMate.Id); + } + + foreach (var task in group.Tasks.ToList()) + { + await _taskModelsRepository.DeleteAsync(task.Id); + } - updated.GroupMates.ForEach(cm => cm.GroupId = groupId); - updated.Tasks.ForEach(cm => cm.GroupId = groupId); - var mateTasks = updated.GroupMates.Select(cm => _groupMatesRepository.AddAsync(cm)); - var idTasks = updated.Tasks.Select(cm => _taskModelsRepository.AddAsync(cm)); + updated.GroupMates?.ForEach(cm => cm.GroupId = groupId); + updated.Tasks?.ForEach(cm => cm.GroupId = groupId); group.Name = updated.Name; - await Task.WhenAll(mateTasks); - await Task.WhenAll(idTasks); + if (updated.GroupMates != null) + { + foreach (var groupMate in updated.GroupMates) + { + await _groupMatesRepository.AddAsync(groupMate); + } + } + + if (updated.Tasks != null) + { + foreach (var task in updated.Tasks) + { + await _taskModelsRepository.AddAsync(task); + } + } } public async Task DeleteGroupMateAsync(long groupId, string studentId) @@ -107,11 +132,15 @@ public async Task GetStudentGroupsAsync(long courseId, s .ToArrayAsync() .ConfigureAwait(false); - var getStudentGroupsTask = studentGroupsIds - .Select(async id => await _groupsRepository.GetAsync(id).ConfigureAwait(false)) - .Where(cm => cm.Result.CourseId == courseId) - .ToArray(); - var studentGroups = await Task.WhenAll(getStudentGroupsTask).ConfigureAwait(false); + var studentGroups = new List(); + foreach (var id in studentGroupsIds) + { + var group = await _groupsRepository.GetAsync(id).ConfigureAwait(false); + if (group.CourseId == courseId) + { + studentGroups.Add(group); + } + } return studentGroups.Select(c => _mapper.Map(c)).ToArray(); } From 8d43277c53bd3441104867449435d6a2c99fda2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 29 Mar 2026 12:41:06 +0300 Subject: [PATCH 21/39] fix: parallel access error in course filter service --- .../Services/CourseFilterService.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index 270496771..207b79dbe 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -72,7 +72,13 @@ public async Task ApplyFiltersToCourses(string userId, CourseDTO[] var filters = (await _courseFilterRepository.GetAsync(userId, courseIds)) .ToDictionary(x => x.CourseId, x => x.CourseFilter); - return (await Task.WhenAll(courses.Select(course => ApplyFilter(course, userId)))).ToArray(); + + var result = new List(); + foreach (var course in courses) + { + result.Add(await ApplyFilter(course, userId)); + } + return result.ToArray(); } public async Task ApplyFilter(CourseDTO courseDto, string userId) From adc7da69276385f4fbf8a6d1ee64f53fa1704580 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 29 Mar 2026 12:43:03 +0300 Subject: [PATCH 22/39] fix: add hw to group mates filters on update --- .../Services/HomeworksService.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs index abf1ba2ac..2850c9614 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs @@ -115,17 +115,18 @@ public async Task UpdateHomeworkAsync(long homeworkId, CreateHomeworkV var homework = await _homeworksRepository.GetAsync(homeworkId); var course = await _coursesRepository.GetWithCourseMates(homework.CourseId); var studentIds = course!.CourseMates.Where(cm => cm.IsAccepted).Select(cm => cm.StudentId).ToArray(); + var notificationStudentIds = studentIds; - if (options.SendNotification && update.PublicationDate <= DateTime.UtcNow) + if (update.GroupId != null) { - var notificationStudentIds = studentIds; + var groupMates = await _groupMatesRepository.FindAll(gm => gm.GroupId == update.GroupId).ToListAsync(); + await UpdateGroupFilters(course.Id, homework.Id, groupMates); - if (update.GroupId != null) - { - var groupMates = await _groupMatesRepository.FindAll(gm => gm.GroupId == update.GroupId).ToListAsync(); - notificationStudentIds = groupMates.Select(gm => gm.StudentId).ToArray(); - } + notificationStudentIds = groupMates.Select(gm => gm.StudentId).ToArray(); + } + if (options.SendNotification && update.PublicationDate <= DateTime.UtcNow) + { _eventBus.Publish(new UpdateHomeworkEvent(update.Title, course.Id, course.Name, notificationStudentIds)); } From 644ca77b945c026c3dec8701036a76ac7d8ac7fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 29 Mar 2026 13:53:52 +0300 Subject: [PATCH 23/39] feat: move creation groups to homework --- .../src/components/Common/GroupSelector.tsx | 319 ++++++++++++++++-- .../src/components/Courses/Course.tsx | 1 + .../components/Courses/CourseExperimental.tsx | 2 + .../src/components/Courses/CourseGroups.tsx | 10 +- .../Homeworks/CourseHomeworkExperimental.tsx | 22 +- 5 files changed, 312 insertions(+), 42 deletions(-) diff --git a/hwproj.front/src/components/Common/GroupSelector.tsx b/hwproj.front/src/components/Common/GroupSelector.tsx index 6723bdef0..db6bed7e3 100644 --- a/hwproj.front/src/components/Common/GroupSelector.tsx +++ b/hwproj.front/src/components/Common/GroupSelector.tsx @@ -2,69 +2,314 @@ import {FC, useEffect, useState} from "react"; import { Grid, TextField, - Autocomplete + Autocomplete, + Button, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Box, + Stack, + Typography, + Tooltip, + Alert, + AlertTitle, + CircularProgress } from "@mui/material"; +import CheckIcon from "@mui/icons-material/Check"; +import EditIcon from "@mui/icons-material/Edit"; +import AddIcon from "@mui/icons-material/Add"; import ApiSingleton from "../../api/ApiSingleton"; -import { Group } from "@/api"; +import { Group, AccountDataDto } from "@/api"; interface GroupSelectorProps { courseId: number, - onGroupIdChange: (groupId?: number) => void - selectedGroupId?: number , - disabled?: boolean, + courseStudents: AccountDataDto[], + onGroupIdChange: (groupId?: number) => void, + onCreateNewGroup?: () => void, + selectedGroupId?: number, + choiceDisabled?: boolean, + selectedGroupStudentIds?: string[], + onGroupsUpdate: () => void, } const GroupSelector: FC = (props) => { - const [groups, setGroups] = useState([]) - const [groupsLoading, setGroupsLoading] = useState(false) + const [groups, setGroups] = useState([]); + const [groupsLoading, setGroupsLoading] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [formState, setFormState] = useState<{ + name: string, + memberIds: string[] + }>({ + name: "", + memberIds: [] + }); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isError, setIsError] = useState(false); const loadGroups = async () => { setGroupsLoading(true) try { - const courseGroups = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(props.courseId) - setGroups(courseGroups) + const courseGroups = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(props.courseId); + setGroups(courseGroups); } catch (error) { - console.error('Failed to load groups:', error) + console.error('Failed to load groups:', error); + setIsError(true); } finally { - setGroupsLoading(false) + setGroupsLoading(false); } } useEffect(() => { - loadGroups() - }, [props.courseId]) + loadGroups(); + }, [props.courseId]); + + const handleOpenEditDialog = () => { + const selectedGroup = groups.find(g => g.id === props.selectedGroupId); + setFormState({ + name: selectedGroup?.name || "", + memberIds: selectedGroup?.studentsIds || [] + }) + setIsDialogOpen(true) + } + + const handleCloseEditDialog = () => { + if (isSubmitting) return; + setIsDialogOpen(false); + setIsError(false); + } + + const handleSubmitEdit = async () => { + setIsSubmitting(true); + try { + const selectedGroup = groups.find(g => g.id === props.selectedGroupId); + + if (selectedGroup) { + await ApiSingleton.courseGroupsApi.courseGroupsUpdateCourseGroup( + props.courseId, + selectedGroup.id!, + { + name: formState.name, + groupMates: formState.memberIds.map(studentId => ({ studentId })), + } + ); + await loadGroups(); + props.onGroupsUpdate(); + } else { + if (!formState.name.trim() || formState.memberIds.length === 0) { + return; + } + + const groupId = await ApiSingleton.courseGroupsApi.courseGroupsCreateCourseGroup(props.courseId, { + name: formState.name.trim(), + groupMatesIds: formState.memberIds, + courseId: props.courseId, + }); + await loadGroups(); + props.onGroupsUpdate(); + props.onGroupIdChange(groupId); + } + setIsDialogOpen(false); + } catch (error) { + console.error('Failed to update group:', error); + setIsError(true); + } finally { + setIsSubmitting(false); + } + } + + const isStudentInGroup = (userId: string, currentGroupId?: number): boolean => { + return groups.some(group => group.id !== currentGroupId && group.studentsIds?.includes(userId)); + } + + const getSortedStudents = (): AccountDataDto[] => { + if (!props.courseStudents) return []; + const currentGroupId = props.selectedGroupId; + const studentsInOtherGroups = props.courseStudents.filter(s => + isStudentInGroup(s.userId!, currentGroupId) + ); + const studentsInCurrentGroup = props.courseStudents.filter(s => + formState.memberIds.includes(s.userId!) + ); + const studentsNotInAnyGroup = props.courseStudents.filter(s => + !isStudentInGroup(s.userId!, currentGroupId) && + !studentsInCurrentGroup.includes(s) + ); + return [...studentsNotInAnyGroup, ...studentsInCurrentGroup, ...studentsInOtherGroups]; + } + + const selectedGroup = groups.find(g => g.id === props.selectedGroupId); return ( - {props.disabled ? ( - g.id === props.selectedGroupId)?.name || "Все студенты"} - variant="outlined" - fullWidth - disabled - /> + {props.choiceDisabled ? ( + + + {selectedGroup && ( + + )} + ) : ( - option.name || ""} - value={props.selectedGroupId !== undefined - ? groups.find(g => g.id === props.selectedGroupId) || null - : { id: undefined, name: "Все студенты" }} - onChange={(_, newGroup) => props.onGroupIdChange(newGroup?.id)} - loading={groupsLoading} - renderInput={(params) => ( - + option.name || ""} + value={props.selectedGroupId !== undefined + ? groups.find(g => g.id === props.selectedGroupId) || null + : { id: undefined, name: "Все студенты" }} + onChange={(_, newGroup) => { + props.onGroupIdChange(newGroup?.id) + }} + loading={groupsLoading} + renderInput={(params) => ( + + )} + /> + {selectedGroup && ( + + )} + {!selectedGroup && ( + )} - /> + )} + + + + {selectedGroup ? "Редактировать группу" : "Создать группу"} + + + {isError && ( + + Ошибка + Не удалось {selectedGroup ? "создать" : "обновить"} группу. Попробуйте позже. + + )} + + + { + setFormState(prev => ({ + ...prev, + name: e.target.value + })) + }} + disabled={isSubmitting || props.choiceDisabled} + /> + + + formState.memberIds.includes(s.userId!)) || []} + getOptionLabel={(option) => + `${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim() + } + renderOption={(materialUIProps, option) => { + const isInOtherGroup = isStudentInGroup(option.userId!, props.choiceDisabled ? props.selectedGroupId : undefined) + return ( + + + + {`${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim()} + + {isInOtherGroup && ( + + + + )} + + + ) + }} + filterSelectedOptions + onChange={(e, values) => { + setFormState(prev => ({ + ...prev, + memberIds: values + .map(x => x.userId!) + .filter(Boolean) + })) + }} + disabled={isSubmitting} + renderInput={(params) => ( + + )} + /> + + + + + + + + ) } -export default GroupSelector \ No newline at end of file +export default GroupSelector diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index ca4da5aa1..474b30b33 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -412,6 +412,7 @@ const Course: React.FC = () => { courseHomeworks: homeworks })) }} + onGroupsUpdate={loadGroups} /> } {tabValue === "stats" && diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index 138b4f225..6c4650d74 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -60,6 +60,7 @@ interface ICourseExperimentalProps { previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onGroupsUpdate: () => void; } interface ICourseExperimentalState { @@ -440,6 +441,7 @@ export const CourseExperimental: FC = (props) => { }} isProcessing={props.processingFiles[homework.id!]?.isLoading || false} onStartProcessing={props.onStartProcessing} + onGroupsUpdate={props.onGroupsUpdate} /> diff --git a/hwproj.front/src/components/Courses/CourseGroups.tsx b/hwproj.front/src/components/Courses/CourseGroups.tsx index b1d4b8039..7b276d83e 100644 --- a/hwproj.front/src/components/Courses/CourseGroups.tsx +++ b/hwproj.front/src/components/Courses/CourseGroups.tsx @@ -1,4 +1,4 @@ -import {FC, useState} from "react"; +import {FC, useState, useEffect} from "react"; import { Accordion, AccordionSummary, @@ -27,7 +27,7 @@ interface ICourseGroupsProps { courseId: number; courseStudents: AccountDataDto[]; groups: Group[]; - onGroupsUpdate: () => Promise; + onGroupsUpdate: () => void; } interface ICreateGroupFormState { @@ -36,10 +36,14 @@ interface ICreateGroupFormState { } const CourseGroups: FC = (props) => { - const {courseId, courseStudents, groups} = props; + const {courseId, courseStudents, groups, onGroupsUpdate} = props; const [isError, setIsError] = useState(false); + useEffect(() => { + onGroupsUpdate(); + }, [courseId]); + const [isDialogOpen, setIsDialogOpen] = useState(false); const [formState, setFormState] = useState({ name: "", diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index d45976cc9..8b3178b59 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -18,7 +18,7 @@ import {IFileInfo} from "components/Files/IFileInfo"; import {FC, useEffect, useState} from "react" import Utils from "services/Utils"; import { - HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel + HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel, AccountDataDto } from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; import Tags from "../Common/Tags"; @@ -64,6 +64,7 @@ const CourseHomeworkEditor: FC<{ previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onGroupsUpdate: () => void; }> = (props) => { const homework = props.homeworkAndFilesInfo.homework const isNewHomework = homework.id! < 0 @@ -116,6 +117,19 @@ const CourseHomeworkEditor: FC<{ const [tags, setTags] = useState(loadedHomework.tags!) const [description, setDescription] = useState(loadedHomework.description!) const [selectedGroupId, setSelectedGroupId] = useState(loadedHomework.groupId) + const [courseStudents, setCourseStudents] = useState([]) + + useEffect(() => { + const loadCourseStudents = async () => { + try { + const courseData = await ApiSingleton.coursesApi.coursesGetAllCourseData(courseId) + setCourseStudents(courseData.course?.acceptedStudents || []) + } catch (error) { + console.error('Failed to load course students:', error) + } + } + loadCourseStudents() + }, [courseId]) const [hasErrors, setHasErrors] = useState(false) @@ -293,9 +307,11 @@ const CourseHomeworkEditor: FC<{ setSelectedGroupId(groupId)} selectedGroupId={selectedGroupId} - disabled={!isNewHomework} + choiceDisabled={!isNewHomework} + onGroupsUpdate={props.onGroupsUpdate} /> {tags.includes(TestTag) && @@ -404,6 +420,7 @@ const CourseHomeworkExperimental: FC<{ previouslyExistingFilesCount: number, waitingNewFilesCount: number, deletingFilesIds: number[]) => void; + onGroupsUpdate: () => void; }> = (props) => { const {homework, filesInfo} = props.homeworkAndFilesInfo const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) @@ -424,6 +441,7 @@ const CourseHomeworkExperimental: FC<{ props.onUpdate(update) }} onStartProcessing={props.onStartProcessing} + onGroupsUpdate={props.onGroupsUpdate} /> return Date: Sun, 29 Mar 2026 14:03:47 +0300 Subject: [PATCH 24/39] feat: remove creation groups from groups tab --- .../src/components/Courses/Course.tsx | 1 - .../src/components/Courses/CourseGroups.tsx | 191 +----------------- 2 files changed, 4 insertions(+), 188 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index 474b30b33..e10574bfd 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -437,7 +437,6 @@ const Course: React.FC = () => { } {tabValue === "groups" && isCourseMentor && void; } -interface ICreateGroupFormState { - name: string; - memberIds: string[]; -} - const CourseGroups: FC = (props) => { - const {courseId, courseStudents, groups, onGroupsUpdate} = props; - - const [isError, setIsError] = useState(false); + const {courseStudents, groups, onGroupsUpdate} = props; useEffect(() => { onGroupsUpdate(); - }, [courseId]); - - const [isDialogOpen, setIsDialogOpen] = useState(false); - const [formState, setFormState] = useState({ - name: "", - memberIds: [] - }); - const [isSubmitting, setIsSubmitting] = useState(false); - - const handleOpenDialog = () => { - setFormState({ - name: "", - memberIds: [] - }); - setIsDialogOpen(true); - }; - - const handleCloseDialog = () => { - if (isSubmitting) return; - setIsDialogOpen(false); - }; - - const handleSubmit = async () => { - if (!formState.name.trim() || formState.memberIds.length === 0) { - return; - } - - setIsSubmitting(true); - try { - await ApiSingleton.courseGroupsApi.courseGroupsCreateCourseGroup(courseId, { - name: formState.name.trim(), - groupMatesIds: formState.memberIds, - courseId: courseId - }); - setIsDialogOpen(false); - await props.onGroupsUpdate(); - } catch { - setIsError(true); - } finally { - setIsSubmitting(false); - } - }; + }, []); const getStudentName = (userId: string) => { const student = courseStudents.find(s => s.userId === userId); @@ -94,16 +33,6 @@ const CourseGroups: FC = (props) => { return `${nameParts.join(" ") || student.email}`; }; - const isStudentInGroup = (userId: string): boolean => { - return groups.some(group => group.studentsIds?.includes(userId)); - }; - - const getSortedStudents = (): AccountDataDto[] => { - const studentsInGroups = courseStudents.filter(s => isStudentInGroup(s.userId!)); - const studentsNotInGroups = courseStudents.filter(s => !isStudentInGroup(s.userId!)); - return [...studentsNotInGroups, ...studentsInGroups]; - }; - return ( @@ -111,26 +40,10 @@ const CourseGroups: FC = (props) => { Группы курса - - {isError && - - - Не удалось загрузить группы - Попробуйте обновить страницу позже. - - - } - - {!isSubmitting && groups.length === 0 && !isError && + {groups.length === 0 && На курсе пока нет групп. @@ -171,102 +84,6 @@ const CourseGroups: FC = (props) => { ); })} - - - - Создать новую группу - - - - - { - e.persist(); - setFormState(prev => ({ - ...prev, - name: e.target.value - })); - }} - /> - - - formState.memberIds.includes(s.userId!))} - getOptionLabel={(option) => - `${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim() - } - renderOption={(props, option) => { - return ( - - - - {`${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim()} - - {isStudentInGroup(option.userId!) && ( - - - - )} - - - ); - }} - filterSelectedOptions - onChange={(e, values) => { - e.persist(); - setFormState(prev => ({ - ...prev, - memberIds: values - .map(x => x.userId!) - .filter(Boolean) - })); - }} - renderInput={(params) => ( - - )} - /> - - - - - - - - ); }; From 7d33478fa74c18d25c57f4e27b8e7938dc21bf65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Mon, 30 Mar 2026 12:41:36 +0300 Subject: [PATCH 25/39] fix: forbid remove student after group creation --- .../src/components/Common/GroupSelector.tsx | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/hwproj.front/src/components/Common/GroupSelector.tsx b/hwproj.front/src/components/Common/GroupSelector.tsx index db6bed7e3..23c4d3016 100644 --- a/hwproj.front/src/components/Common/GroupSelector.tsx +++ b/hwproj.front/src/components/Common/GroupSelector.tsx @@ -14,7 +14,8 @@ import { Tooltip, Alert, AlertTitle, - CircularProgress + CircularProgress, + Chip } from "@mui/material"; import CheckIcon from "@mui/icons-material/Check"; import EditIcon from "@mui/icons-material/Edit"; @@ -27,11 +28,10 @@ interface GroupSelectorProps { courseId: number, courseStudents: AccountDataDto[], onGroupIdChange: (groupId?: number) => void, - onCreateNewGroup?: () => void, + onGroupsUpdate: () => void, selectedGroupId?: number, choiceDisabled?: boolean, - selectedGroupStudentIds?: string[], - onGroupsUpdate: () => void, + onCreateNewGroup?: () => void, } const GroupSelector: FC = (props) => { @@ -270,15 +270,34 @@ const GroupSelector: FC = (props) => { ) }} filterSelectedOptions - onChange={(e, values) => { - setFormState(prev => ({ - ...prev, - memberIds: values - .map(x => x.userId!) - .filter(Boolean) - })) + onChange={(_, values) => { + if (selectedGroup) { + // При редактировании выбранной группы можно только добавлять студентов + setFormState(prev => ({ + ...prev, + memberIds: [...formState.memberIds, + ...values.map(x => !formState.memberIds.includes(x.userId!) ? x.userId! : "").filter(Boolean)] + })) + } else { + setFormState(prev => ({ + ...prev, + memberIds: values + .map(x => x.userId!) + .filter(Boolean) + })) + } }} disabled={isSubmitting} + renderTags={(tagValue, getTagProps) => + tagValue.map((option, index) => ( + + )) + } renderInput={(params) => ( Date: Tue, 31 Mar 2026 21:23:43 +0300 Subject: [PATCH 26/39] fix: only mentor can see all group homeworks --- .../HwProj.CoursesService.API/Services/CourseFilterService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index 207b79dbe..f50f3d520 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -93,7 +93,7 @@ public async Task ApplyFilter(CourseDTO courseDto, string userId) (await _courseFilterRepository.GetAsync(findFiltersFor, courseDto.Id)) .ToDictionary(x => x.UserId, x => x.CourseFilter); - if (isCourseStudent) + if (!isMentor) { var studentCourse = courseDto; var groupFilter = await _courseFilterRepository.GetAsync("", courseDto.Id); // Глобальный фильтр для вычитания групповых домашних заданий From ebf7ba2f2c9522241f065bc7a7464528cbd72c0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sat, 4 Apr 2026 13:13:11 +0300 Subject: [PATCH 27/39] fix: group homework cell in stats --- .../src/components/Courses/StudentStats.tsx | 17 +++++++++- .../Courses/Styles/StudentStatsCell.css | 15 ++++++++ .../src/components/Tasks/StudentStatsCell.tsx | 34 ++++++++++++++----- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/hwproj.front/src/components/Courses/StudentStats.tsx b/hwproj.front/src/components/Courses/StudentStats.tsx index 61e38becb..6c04bf594 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -1,5 +1,5 @@ import React, {useEffect, useState, useRef} from "react"; -import {CourseViewModel, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; +import {CourseViewModel, Group, HomeworkViewModel, StatisticsCourseMatesModel} from "@/api"; import {useNavigate, useParams} from 'react-router-dom'; import {LinearProgress, Table, TableBody, TableCell, TableContainer, TableHead, TableRow} from "@material-ui/core"; import StudentStatsCell from "../Tasks/StudentStatsCell"; @@ -148,6 +148,19 @@ const StudentStats: React.FC = (props) => { .forEach(x => bestTaskSolutions.set(x.taskId!, x.studentId!)) } + const [groups, setGroups] = useState([]); + useEffect(() => { + const loadGroups = async () => { + try { + const courseGroups = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(+courseId!); + setGroups(courseGroups); + } catch (error) { + console.error('Failed to load groups:', error); + } + }; + loadGroups(); + }, [courseId]); + return (
{props.solutions === undefined && } @@ -356,6 +369,7 @@ const StudentStats: React.FC = (props) => { {homeworks.map((homework, idx) => homework.tasks!.map((task, i) => { const additionalStyles = i === 0 && homeworkStyles(homeworks, idx) + const isDisabled = homework.groupId ? !groups.find(g => g.id === homework.groupId)?.studentsIds?.includes(cm.id!) : false return = (props) => { taskId={task.id!} taskMaxRating={task.maxRating!} isBestSolution={bestTaskSolutions.get(task.id!) === cm.id} + disabled={isDisabled} {...additionalStyles}/>; }) )} diff --git a/hwproj.front/src/components/Courses/Styles/StudentStatsCell.css b/hwproj.front/src/components/Courses/Styles/StudentStatsCell.css index 4bd62d251..4d0d7a5f1 100644 --- a/hwproj.front/src/components/Courses/Styles/StudentStatsCell.css +++ b/hwproj.front/src/components/Courses/Styles/StudentStatsCell.css @@ -22,3 +22,18 @@ .glow-cell { animation: golden-glow-strongest-inset 2.5s infinite ease-in-out; } + +.disabled-cell { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; +} + +.disabled-cell .red-cross { + color: #d32f2f; + font-size: 1.5rem; + font-weight: bold; + line-height: 1; +} diff --git a/hwproj.front/src/components/Tasks/StudentStatsCell.tsx b/hwproj.front/src/components/Tasks/StudentStatsCell.tsx index a81501135..6b2b6aacd 100644 --- a/hwproj.front/src/components/Tasks/StudentStatsCell.tsx +++ b/hwproj.front/src/components/Tasks/StudentStatsCell.tsx @@ -17,6 +17,7 @@ interface ITaskStudentCellProps { taskMaxRating: number; isBestSolution: boolean; solutions?: SolutionDto[]; + disabled?: boolean; } const StudentStatsCell: FC = (props) => { @@ -27,11 +28,23 @@ const StudentStatsCell: FC const {ratedSolutionsCount, solutionsDescription} = cellState; - const tooltipTitle = ratedSolutionsCount === 0 - ? solutionsDescription - : solutionsDescription - + (props.isBestSolution ? "\n Первое решение с лучшей оценкой" : "") - + `\n\n${Utils.pluralizeHelper(["Проверена", "Проверены", "Проверено"], ratedSolutionsCount)} ${ratedSolutionsCount} ${Utils.pluralizeHelper(["попытка", "попытки", "попыток"], ratedSolutionsCount)}`; + const buildTitle = (): string => { + if (props.disabled) { + return "Задача недоступна для этого студента"; + } + + if (ratedSolutionsCount === 0) { + return solutionsDescription; + } + + const bestSolutionNote = props.isBestSolution + ? "\n Первое решение с лучшей оценкой" + : ""; + + const attemptsInfo = `\n\n${Utils.pluralizeHelper(["Проверена", "Проверены", "Проверено"], ratedSolutionsCount)} ${ratedSolutionsCount} ${Utils.pluralizeHelper(["попытка", "попытки", "попыток"], ratedSolutionsCount)}`; + + return solutionsDescription + bestSolutionNote + attemptsInfo; + }; const result = cellState.lastRatedSolution === undefined ? "" @@ -41,6 +54,7 @@ const StudentStatsCell: FC ; const handleCellClick = (e: React.MouseEvent) => { + if(props.disabled) return; // Формируем URL const url = forMentor ? `/task/${props.taskId}/${props.studentId}` @@ -59,7 +73,7 @@ const StudentStatsCell: FC return ( {tooltipTitle}}> + title={{buildTitle()}}> style={{ backgroundColor: cellState.color, borderLeft: `1px solid ${props.borderLeftColor || grey[300]}`, - cursor: "pointer", + cursor: props.disabled ? "default" : "pointer", }}> - {result} + {props.disabled + ?
+ +
+ : result}
); From a815e53f9d88b523c32a744b13096534df1746f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sat, 4 Apr 2026 13:16:20 +0300 Subject: [PATCH 28/39] fix: max homeworks rate counting --- .../src/components/Courses/StudentStats.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/hwproj.front/src/components/Courses/StudentStats.tsx b/hwproj.front/src/components/Courses/StudentStats.tsx index 6c04bf594..ecd83065c 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -104,13 +104,6 @@ const StudentStats: React.FC = (props) => { const notTests = homeworks.filter(h => !h.tags!.includes(TestTag)) - const homeworksMaxSum = notTests - .filter(h => !h.tags!.includes(BonusTag)) - .flatMap(homework => homework.tasks) - .reduce((sum, task) => { - return sum + (task!.tags!.includes(BonusTag) ? 0 : (task!.maxRating || 0)); - }, 0) - const testGroups = Lodash(homeworks.filter(h => h.tags!.includes(TestTag))) .groupBy((h: HomeworkViewModel) => { const key = h.tags!.find(t => !DefaultTags.includes(t)) @@ -125,7 +118,10 @@ const StudentStats: React.FC = (props) => { .reduce((sum, task) => sum + (task!.tags!.includes(BonusTag) ? 0 : (task!.maxRating || 0)), 0) - const hasHomeworks = homeworksMaxSum > 0 + const hasHomeworks = !!notTests + .filter(h => !h.tags!.includes(BonusTag)) + .flatMap(homework => homework.tasks) + .filter(task => !task!.tags!.includes(BonusTag)) const hasTests = testsMaxSum > 0 const showBestSolutions = isMentor && (hasHomeworks || hasTests) @@ -230,7 +226,7 @@ const StudentStats: React.FC = (props) => { paddingRight: 5, borderLeft: borderStyle, }}> - ДЗ ({homeworksMaxSum}) + ДЗ } {hasTests && = (props) => { .flatMap(t => StudentStatsUtils.calculateLastRatedSolution(t.solutions || [])?.rating || 0) || 0 ) .reduce((sum, rating) => sum + rating, 0) + const homeworksMaxSum = notTests + .filter(h => !h.tags!.includes(BonusTag) && + (groups.find(g => g.id === h.groupId)?.studentsIds?.includes(cm.id!) || !h.groupId)) + .flatMap(homework => homework.tasks) + .reduce((sum, task) => { + return sum + (task!.tags!.includes(BonusTag) ? 0 : (task!.maxRating || 0)); + }, 0) const testsSum = testGroups .map(group => { From 6a7f382a254ad1f0dd3344809e9c5e9ec26c855d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sat, 4 Apr 2026 13:27:12 +0300 Subject: [PATCH 29/39] fix: sort homeworks after apply filter --- .../HwProj.CoursesService.API/Services/CourseFilterService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index f50f3d520..9dc73d4d1 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -206,7 +206,7 @@ private async Task ApplyFilterInternal(CourseDTO courseDto, CourseFil ? courseDto.CourseMates .Where(mate => !mate.IsAccepted || filter.StudentIds.Contains(mate.StudentId)).ToArray() : courseDto.CourseMates, - Homeworks = homeworks + Homeworks = homeworks.OrderBy(hw => hw.PublicationDate).ToArray() }; } From 03aa2a6d8bdc53e22700d976cabe0b54afbed67e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sat, 4 Apr 2026 15:37:49 +0300 Subject: [PATCH 30/39] refactor: optimize sequantial db access for groups --- .../Services/GroupsService.cs | 31 +++++++------------ 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs index 3656845aa..ad543f173 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/GroupsService.cs @@ -86,20 +86,14 @@ public async Task UpdateAsync(long groupId, Group updated) group.Name = updated.Name; - if (updated.GroupMates != null) + if (updated.GroupMates != null && updated.GroupMates.Count > 0) { - foreach (var groupMate in updated.GroupMates) - { - await _groupMatesRepository.AddAsync(groupMate); - } + await _groupMatesRepository.AddRangeAsync(updated.GroupMates).ConfigureAwait(false); } - if (updated.Tasks != null) + if (updated.Tasks != null && updated.Tasks.Count > 0) { - foreach (var task in updated.Tasks) - { - await _taskModelsRepository.AddAsync(task); - } + await _taskModelsRepository.AddRangeAsync(updated.Tasks).ConfigureAwait(false); } } @@ -132,17 +126,14 @@ public async Task GetStudentGroupsAsync(long courseId, s .ToArrayAsync() .ConfigureAwait(false); - var studentGroups = new List(); - foreach (var id in studentGroupsIds) - { - var group = await _groupsRepository.GetAsync(id).ConfigureAwait(false); - if (group.CourseId == courseId) - { - studentGroups.Add(group); - } - } + var studentGroups = await _groupsRepository + .GetGroupsWithGroupMatesAsync(studentGroupsIds) + .ConfigureAwait(false); - return studentGroups.Select(c => _mapper.Map(c)).ToArray(); + return studentGroups + .Where(g => g.CourseId == courseId) + .Select(c => _mapper.Map(c)) + .ToArray(); } } } From 33cf41ff11eafb79114b229110bc1aad66182706 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sat, 4 Apr 2026 15:39:32 +0300 Subject: [PATCH 31/39] feat: get many homeworks method --- .../Repositories/HomeworksRepository.cs | 8 ++++++++ .../Repositories/IHomeworksRepository.cs | 1 + .../Services/HomeworksService.cs | 12 ++++++++++++ .../Services/IHomeworksService.cs | 2 ++ 4 files changed, 23 insertions(+) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/HomeworksRepository.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/HomeworksRepository.cs index aa663be58..6c41b669b 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/HomeworksRepository.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/HomeworksRepository.cs @@ -27,6 +27,14 @@ public async Task GetAllWithTasksByCourseAsync(long courseId) .ToArrayAsync(); } + public async Task GetWithTasksAsync(long[] homeworkIds, bool withCriteria = false) + { + var query = Context.Set().AsNoTracking().Include(h => h.Tasks); + return withCriteria + ? await query.ThenInclude(x => x.Criteria).Where(h => homeworkIds.Contains(h.Id)).ToArrayAsync() + : await query.Where(h => homeworkIds.Contains(h.Id)).ToArrayAsync(); + } + public async Task GetWithTasksAsync(long id, bool withCriteria = false) { var query = Context.Set().AsNoTracking().Include(h => h.Tasks); diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IHomeworksRepository.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IHomeworksRepository.cs index 4c05afdaf..d887b6d21 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IHomeworksRepository.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Repositories/IHomeworksRepository.cs @@ -8,6 +8,7 @@ public interface IHomeworksRepository : ICrudRepository { Task GetAllWithTasksAsync(); Task GetAllWithTasksByCourseAsync(long courseId); + Task GetWithTasksAsync(long[] homeworkIds, bool withCriteria = false); Task GetWithTasksAsync(long id, bool withCriteria = false); } } diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs index 2850c9614..69e677b88 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/HomeworksService.cs @@ -68,6 +68,18 @@ public async Task GetHomeworkAsync(long homeworkId, bool withCriteria return homework; } + public async Task GetHomeworksAsync(long[] homeworkIds, bool withCriteria = false) + { + var homeworks = await _homeworksRepository.GetWithTasksAsync(homeworkIds, withCriteria); + + foreach (var homework in homeworks) + { + CourseDomain.FillTasksInHomework(homework); + } + + return homeworks; + } + public async Task GetForEditingHomeworkAsync(long homeworkId) { var result = await _homeworksRepository.GetWithTasksAsync(homeworkId); diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/IHomeworksService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/IHomeworksService.cs index 2b2460c37..606fea6d7 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/IHomeworksService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/IHomeworksService.cs @@ -10,6 +10,8 @@ public interface IHomeworksService Task GetHomeworkAsync(long homeworkId, bool withCriteria = false); + Task GetHomeworksAsync(long[] homeworkIds, bool withCriteria = false); + Task GetForEditingHomeworkAsync(long homeworkId); Task DeleteHomeworkAsync(long homeworkId); From 075adea1535f1300e8a3d128160760ded2a35533 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sat, 4 Apr 2026 15:40:29 +0300 Subject: [PATCH 32/39] refactor: use many homeworks get method in filter service --- .../Services/CourseFilterService.cs | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs index 9dc73d4d1..1a059fd93 100644 --- a/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs +++ b/HwProj.CoursesService/HwProj.CoursesService.API/Services/CourseFilterService.cs @@ -70,9 +70,6 @@ public async Task ApplyFiltersToCourses(string userId, CourseDTO[] { var courseIds = courses.Select(c => c.Id).ToArray(); - var filters = (await _courseFilterRepository.GetAsync(userId, courseIds)) - .ToDictionary(x => x.CourseId, x => x.CourseFilter); - var result = new List(); foreach (var course in courses) { @@ -167,7 +164,11 @@ private async Task ApplyFilterInternal(CourseDTO courseDto, CourseFil .ToArray(), ApplyFilterType.Union => courseDto.Homeworks - .Union(await GetHomeworksSequentially(filter.HomeworkIds)) + .Concat((await _homeworksService.GetHomeworksAsync(filter.HomeworkIds.ToArray())) + .Where(hw => hw != null) + .Select(hw => hw.ToHomeworkViewModel())) + .GroupBy(hw => hw.Id) + .Select(g => g.First()) .ToArray(), _ => courseDto.Homeworks @@ -209,17 +210,5 @@ private async Task ApplyFilterInternal(CourseDTO courseDto, CourseFil Homeworks = homeworks.OrderBy(hw => hw.PublicationDate).ToArray() }; } - - private async Task> GetHomeworksSequentially(List homeworkIds) - { - var result = new List(); - foreach (var id in homeworkIds) - { - var hw = await _homeworksService.GetHomeworkAsync(id); - if (hw != null) - result.Add(hw.ToHomeworkViewModel()); - } - return result; - } } } From 9769fa95cdc82cf2ae83054f62a95486c75c1303 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 5 Apr 2026 21:36:21 +0300 Subject: [PATCH 33/39] fix: can choose only students without group --- .../src/components/Common/GroupSelector.tsx | 54 +++---------------- 1 file changed, 6 insertions(+), 48 deletions(-) diff --git a/hwproj.front/src/components/Common/GroupSelector.tsx b/hwproj.front/src/components/Common/GroupSelector.tsx index 23c4d3016..be31709b4 100644 --- a/hwproj.front/src/components/Common/GroupSelector.tsx +++ b/hwproj.front/src/components/Common/GroupSelector.tsx @@ -1,4 +1,4 @@ -import {FC, useEffect, useState} from "react"; +import {FC, useEffect, useMemo, useState} from "react"; import { Grid, TextField, @@ -8,16 +8,12 @@ import { DialogTitle, DialogContent, DialogActions, - Box, Stack, - Typography, - Tooltip, Alert, AlertTitle, CircularProgress, Chip } from "@mui/material"; -import CheckIcon from "@mui/icons-material/Check"; import EditIcon from "@mui/icons-material/Edit"; import AddIcon from "@mui/icons-material/Add"; import ApiSingleton from "../../api/ApiSingleton"; @@ -118,25 +114,10 @@ const GroupSelector: FC = (props) => { } } - const isStudentInGroup = (userId: string, currentGroupId?: number): boolean => { - return groups.some(group => group.id !== currentGroupId && group.studentsIds?.includes(userId)); - } - - const getSortedStudents = (): AccountDataDto[] => { - if (!props.courseStudents) return []; - const currentGroupId = props.selectedGroupId; - const studentsInOtherGroups = props.courseStudents.filter(s => - isStudentInGroup(s.userId!, currentGroupId) - ); - const studentsInCurrentGroup = props.courseStudents.filter(s => - formState.memberIds.includes(s.userId!) - ); - const studentsNotInAnyGroup = props.courseStudents.filter(s => - !isStudentInGroup(s.userId!, currentGroupId) && - !studentsInCurrentGroup.includes(s) - ); - return [...studentsNotInAnyGroup, ...studentsInCurrentGroup, ...studentsInOtherGroups]; - } + const studentsWithousGroup = useMemo(() => { + const studentsInGroups = groups.flatMap(g => g.studentsIds) + return props.courseStudents.filter((cm) => !studentsInGroups.includes(cm.userId)) + }, [groups, props.courseStudents]); const selectedGroup = groups.find(g => g.id === props.selectedGroupId); @@ -241,34 +222,11 @@ const GroupSelector: FC = (props) => { formState.memberIds.includes(s.userId!)) || []} getOptionLabel={(option) => `${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim() } - renderOption={(materialUIProps, option) => { - const isInOtherGroup = isStudentInGroup(option.userId!, props.choiceDisabled ? props.selectedGroupId : undefined) - return ( - - - - {`${option.surname ?? ""} ${option.name ?? ""} / ${option.email ?? ""}`.trim()} - - {isInOtherGroup && ( - - - - )} - - - ) - }} filterSelectedOptions onChange={(_, values) => { if (selectedGroup) { From dc729168aae9165a68590bc2136e60e03afcbb6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 5 Apr 2026 21:39:20 +0300 Subject: [PATCH 34/39] refactor: students without group filter --- hwproj.front/src/components/Courses/Course.tsx | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index e10574bfd..f54b94e4b 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -175,17 +175,10 @@ const Course: React.FC = () => { const [groups, setGroups] = useState([]); const [groupLoadingError, setGroupLoadingError] = useState(false); - const studentsInGroups = useMemo(() => { - const studentIds = new Set(); - groups.forEach(g => { - g.studentsIds?.forEach(id => studentIds.add(id)); - }); - return studentIds; - }, [groups]); - const studentsWithoutGroup = useMemo(() => { - return acceptedStudents.filter(s => !studentsInGroups.has(s.userId!)); - }, [acceptedStudents, studentsInGroups]); + const inGroupIds = new Set(groups.flatMap(g => g.studentsIds)); + return acceptedStudents.filter(s => !inGroupIds.has(s.userId!)); + }, [groups, acceptedStudents]); const loadGroups = async () => { setGroupLoadingError(false); From 1b47e5de99f1a69a1674f93509bed95909e85279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 5 Apr 2026 21:41:45 +0300 Subject: [PATCH 35/39] refactor: styles calculation --- .../Courses/Styles/StudentStatsCell.css | 15 --------------- hwproj.front/src/services/StudentStatsUtils.ts | 18 +++++++++++++----- 2 files changed, 13 insertions(+), 20 deletions(-) diff --git a/hwproj.front/src/components/Courses/Styles/StudentStatsCell.css b/hwproj.front/src/components/Courses/Styles/StudentStatsCell.css index 4d0d7a5f1..4bd62d251 100644 --- a/hwproj.front/src/components/Courses/Styles/StudentStatsCell.css +++ b/hwproj.front/src/components/Courses/Styles/StudentStatsCell.css @@ -22,18 +22,3 @@ .glow-cell { animation: golden-glow-strongest-inset 2.5s infinite ease-in-out; } - -.disabled-cell { - display: flex; - justify-content: center; - align-items: center; - width: 100%; - height: 100%; -} - -.disabled-cell .red-cross { - color: #d32f2f; - font-size: 1.5rem; - font-weight: bold; - line-height: 1; -} diff --git a/hwproj.front/src/services/StudentStatsUtils.ts b/hwproj.front/src/services/StudentStatsUtils.ts index 3597c9948..eefdebebd 100644 --- a/hwproj.front/src/services/StudentStatsUtils.ts +++ b/hwproj.front/src/services/StudentStatsUtils.ts @@ -22,7 +22,7 @@ export default class StudentStatsUtils { return ratedSolutions.slice(-1)[0] } - static calculateLastRatedSolutionInfo(solutions: SolutionDto[], taskMaxRating: number) { + static calculateLastRatedSolutionInfo(solutions: SolutionDto[], taskMaxRating: number, disabled: boolean = false) { const ratedSolutions = solutions!.filter(x => x.state !== SolutionState.NUMBER_0) const ratedSolutionsCount = ratedSolutions.length const isFirstUnratedTry = ratedSolutionsCount === 0 @@ -30,7 +30,9 @@ export default class StudentStatsUtils { const lastRatedSolution = ratedSolutions.slice(-1)[0] let solutionsDescription: string - if (lastSolution === undefined) + if(disabled) + solutionsDescription = "Задача недоступна для этого студента" + else if (lastSolution === undefined) solutionsDescription = "Решение отсутствует" else if (isFirstUnratedTry) solutionsDescription = "Решение ожидает проверки" @@ -38,11 +40,17 @@ export default class StudentStatsUtils { solutionsDescription = `${lastSolution.rating}/${taskMaxRating} ${Utils.pluralizeHelper(["балл", "балла", "баллов"], taskMaxRating)}` else solutionsDescription = "Последняя оценка — " + `${lastRatedSolution.rating}/${taskMaxRating} ${Utils.pluralizeHelper(["балл", "балла", "баллов"], taskMaxRating)}\nНовое решение ожидает проверки` + let color: string + if(disabled) + color = "#d1d1d1" + else if (lastRatedSolution == undefined) + color = "#ffffff" + else + color = StudentStatsUtils.getCellBackgroundColor(lastRatedSolution.state, lastRatedSolution.rating, taskMaxRating, isFirstUnratedTry) + return { lastRatedSolution: lastRatedSolution, - color: lastSolution === undefined - ? "#ffffff" - : StudentStatsUtils.getCellBackgroundColor(lastSolution.state, lastSolution.rating, taskMaxRating, isFirstUnratedTry), + color: color, ratedSolutionsCount, lastSolution, solutionsDescription } } From 3a4269034bb9cb169cba5d4841fe8cf5d0ca7138 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Sun, 5 Apr 2026 21:42:25 +0300 Subject: [PATCH 36/39] feat: show max homework sum --- hwproj.front/src/components/Courses/StudentStats.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hwproj.front/src/components/Courses/StudentStats.tsx b/hwproj.front/src/components/Courses/StudentStats.tsx index ecd83065c..e5c0df109 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -337,7 +337,7 @@ const StudentStats: React.FC = (props) => { backgroundColor: StudentStatsUtils.getRatingColor(homeworksSum, homeworksMaxSum), fontSize: 16 }} - label={homeworksSum}/> + label={`${homeworksSum} / ${homeworksMaxSum}`}/> } {hasTests && Date: Sun, 5 Apr 2026 21:43:43 +0300 Subject: [PATCH 37/39] fix: gray cell instead of red cross --- .../src/components/Tasks/StudentStatsCell.tsx | 32 ++++++------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/hwproj.front/src/components/Tasks/StudentStatsCell.tsx b/hwproj.front/src/components/Tasks/StudentStatsCell.tsx index 6b2b6aacd..dc27b864e 100644 --- a/hwproj.front/src/components/Tasks/StudentStatsCell.tsx +++ b/hwproj.front/src/components/Tasks/StudentStatsCell.tsx @@ -24,27 +24,16 @@ const StudentStatsCell: FC const navigate = useNavigate() const {solutions, taskMaxRating, forMentor} = props - const cellState = StudentStatsUtils.calculateLastRatedSolutionInfo(solutions!, taskMaxRating) + const cellState = StudentStatsUtils.calculateLastRatedSolutionInfo(solutions!, taskMaxRating, props.disabled) const {ratedSolutionsCount, solutionsDescription} = cellState; - const buildTitle = (): string => { - if (props.disabled) { - return "Задача недоступна для этого студента"; - } - - if (ratedSolutionsCount === 0) { - return solutionsDescription; - } - - const bestSolutionNote = props.isBestSolution - ? "\n Первое решение с лучшей оценкой" - : ""; + const tooltipTitle = ratedSolutionsCount === 0 + ? solutionsDescription + : solutionsDescription + + (props.isBestSolution ? "\n Первое решение с лучшей оценкой" : "") + + `\n\n${Utils.pluralizeHelper(["Проверена", "Проверены", "Проверено"], ratedSolutionsCount)} ${ratedSolutionsCount} ${Utils.pluralizeHelper(["попытка", "попытки", "попыток"], ratedSolutionsCount)}`; - const attemptsInfo = `\n\n${Utils.pluralizeHelper(["Проверена", "Проверены", "Проверено"], ratedSolutionsCount)} ${ratedSolutionsCount} ${Utils.pluralizeHelper(["попытка", "попытки", "попыток"], ratedSolutionsCount)}`; - - return solutionsDescription + bestSolutionNote + attemptsInfo; - }; const result = cellState.lastRatedSolution === undefined ? "" @@ -55,6 +44,7 @@ const StudentStatsCell: FC const handleCellClick = (e: React.MouseEvent) => { if(props.disabled) return; + // Формируем URL const url = forMentor ? `/task/${props.taskId}/${props.studentId}` @@ -73,7 +63,7 @@ const StudentStatsCell: FC return ( {buildTitle()}}> + title={{tooltipTitle}}> borderLeft: `1px solid ${props.borderLeftColor || grey[300]}`, cursor: props.disabled ? "default" : "pointer", }}> - {props.disabled - ?
- -
- : result} + {result}
); From f2dcc7d2a944aa6b96f034e199619b00cb62a418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D1=81=D0=B8=D0=BD=20=D0=A1=D0=B5=D0=BC=D1=91?= =?UTF-8?q?=D0=BD?= Date: Thu, 9 Apr 2026 18:35:01 +0300 Subject: [PATCH 38/39] refactor: remove excess api calls (groups in stats) --- .../src/components/Common/GroupSelector.tsx | 2 +- .../src/components/Courses/Course.tsx | 1 + .../src/components/Courses/StudentStats.tsx | 20 +++++-------------- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/hwproj.front/src/components/Common/GroupSelector.tsx b/hwproj.front/src/components/Common/GroupSelector.tsx index be31709b4..e5808a2cf 100644 --- a/hwproj.front/src/components/Common/GroupSelector.tsx +++ b/hwproj.front/src/components/Common/GroupSelector.tsx @@ -158,7 +158,7 @@ const GroupSelector: FC = (props) => { renderInput={(params) => ( diff --git a/hwproj.front/src/components/Courses/Course.tsx b/hwproj.front/src/components/Courses/Course.tsx index f54b94e4b..1b77bad77 100644 --- a/hwproj.front/src/components/Courses/Course.tsx +++ b/hwproj.front/src/components/Courses/Course.tsx @@ -417,6 +417,7 @@ const Course: React.FC = () => { isMentor={isCourseMentor} course={courseState.course} solutions={studentSolutions} + groups={groups} /> } diff --git a/hwproj.front/src/components/Courses/StudentStats.tsx b/hwproj.front/src/components/Courses/StudentStats.tsx index e5c0df109..36cdd85b4 100644 --- a/hwproj.front/src/components/Courses/StudentStats.tsx +++ b/hwproj.front/src/components/Courses/StudentStats.tsx @@ -19,6 +19,7 @@ interface IStudentStatsProps { isMentor: boolean; userId: string; solutions: StatisticsCourseMatesModel[] | undefined; + groups: Group[]; } interface IStudentStatsState { @@ -144,19 +145,6 @@ const StudentStats: React.FC = (props) => { .forEach(x => bestTaskSolutions.set(x.taskId!, x.studentId!)) } - const [groups, setGroups] = useState([]); - useEffect(() => { - const loadGroups = async () => { - try { - const courseGroups = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(+courseId!); - setGroups(courseGroups); - } catch (error) { - console.error('Failed to load groups:', error); - } - }; - loadGroups(); - }, [courseId]); - return (
{props.solutions === undefined && } @@ -270,7 +258,7 @@ const StudentStats: React.FC = (props) => { .reduce((sum, rating) => sum + rating, 0) const homeworksMaxSum = notTests .filter(h => !h.tags!.includes(BonusTag) && - (groups.find(g => g.id === h.groupId)?.studentsIds?.includes(cm.id!) || !h.groupId)) + (props.groups.find(g => g.id === h.groupId)?.studentsIds?.includes(cm.id!) || !h.groupId)) .flatMap(homework => homework.tasks) .reduce((sum, task) => { return sum + (task!.tags!.includes(BonusTag) ? 0 : (task!.maxRating || 0)); @@ -372,7 +360,9 @@ const StudentStats: React.FC = (props) => { {homeworks.map((homework, idx) => homework.tasks!.map((task, i) => { const additionalStyles = i === 0 && homeworkStyles(homeworks, idx) - const isDisabled = homework.groupId ? !groups.find(g => g.id === homework.groupId)?.studentsIds?.includes(cm.id!) : false + const isDisabled = homework.groupId + ? !props.groups.find(g => g.id === homework.groupId)?.studentsIds?.includes(cm.id!) + : false return Date: Thu, 9 Apr 2026 18:46:47 +0300 Subject: [PATCH 39/39] refactor: remove excess api calls (group selector) --- .../src/components/Common/GroupSelector.tsx | 25 +++---------------- .../src/components/Courses/Course.tsx | 1 + .../components/Courses/CourseExperimental.tsx | 3 +++ .../Homeworks/CourseHomeworkExperimental.tsx | 7 +++++- 4 files changed, 13 insertions(+), 23 deletions(-) diff --git a/hwproj.front/src/components/Common/GroupSelector.tsx b/hwproj.front/src/components/Common/GroupSelector.tsx index e5808a2cf..b92b916ed 100644 --- a/hwproj.front/src/components/Common/GroupSelector.tsx +++ b/hwproj.front/src/components/Common/GroupSelector.tsx @@ -1,4 +1,4 @@ -import {FC, useEffect, useMemo, useState} from "react"; +import {FC, useMemo, useState} from "react"; import { Grid, TextField, @@ -23,6 +23,7 @@ import { Group, AccountDataDto } from "@/api"; interface GroupSelectorProps { courseId: number, courseStudents: AccountDataDto[], + groups: Group[], onGroupIdChange: (groupId?: number) => void, onGroupsUpdate: () => void, selectedGroupId?: number, @@ -31,8 +32,7 @@ interface GroupSelectorProps { } const GroupSelector: FC = (props) => { - const [groups, setGroups] = useState([]); - const [groupsLoading, setGroupsLoading] = useState(false); + const groups = props.groups || []; const [isDialogOpen, setIsDialogOpen] = useState(false); const [formState, setFormState] = useState<{ name: string, @@ -44,22 +44,6 @@ const GroupSelector: FC = (props) => { const [isSubmitting, setIsSubmitting] = useState(false); const [isError, setIsError] = useState(false); - const loadGroups = async () => { - setGroupsLoading(true) - try { - const courseGroups = await ApiSingleton.courseGroupsApi.courseGroupsGetAllCourseGroupsWithNames(props.courseId); - setGroups(courseGroups); - } catch (error) { - console.error('Failed to load groups:', error); - setIsError(true); - } finally { - setGroupsLoading(false); - } - } - useEffect(() => { - loadGroups(); - }, [props.courseId]); - const handleOpenEditDialog = () => { const selectedGroup = groups.find(g => g.id === props.selectedGroupId); setFormState({ @@ -89,7 +73,6 @@ const GroupSelector: FC = (props) => { groupMates: formState.memberIds.map(studentId => ({ studentId })), } ); - await loadGroups(); props.onGroupsUpdate(); } else { if (!formState.name.trim() || formState.memberIds.length === 0) { @@ -101,7 +84,6 @@ const GroupSelector: FC = (props) => { groupMatesIds: formState.memberIds, courseId: props.courseId, }); - await loadGroups(); props.onGroupsUpdate(); props.onGroupIdChange(groupId); } @@ -154,7 +136,6 @@ const GroupSelector: FC = (props) => { onChange={(_, newGroup) => { props.onGroupIdChange(newGroup?.id) }} - loading={groupsLoading} renderInput={(params) => ( { })) }} onGroupsUpdate={loadGroups} + groups={groups} /> } {tabValue === "stats" && diff --git a/hwproj.front/src/components/Courses/CourseExperimental.tsx b/hwproj.front/src/components/Courses/CourseExperimental.tsx index 6c4650d74..a060359fc 100644 --- a/hwproj.front/src/components/Courses/CourseExperimental.tsx +++ b/hwproj.front/src/components/Courses/CourseExperimental.tsx @@ -1,6 +1,7 @@ import * as React from "react"; import { FileInfoDTO, + Group, HomeworkTaskViewModel, HomeworkViewModel, SolutionDto, StatisticsCourseMatesModel, } from "@/api"; @@ -61,6 +62,7 @@ interface ICourseExperimentalProps { waitingNewFilesCount: number, deletingFilesIds: number[]) => void; onGroupsUpdate: () => void; + groups: Group[]; } interface ICourseExperimentalState { @@ -442,6 +444,7 @@ export const CourseExperimental: FC = (props) => { isProcessing={props.processingFiles[homework.id!]?.isLoading || false} onStartProcessing={props.onStartProcessing} onGroupsUpdate={props.onGroupsUpdate} + groups={props.groups} /> diff --git a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx index 8b3178b59..add5dbfdd 100644 --- a/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx +++ b/hwproj.front/src/components/Homeworks/CourseHomeworkExperimental.tsx @@ -18,7 +18,8 @@ import {IFileInfo} from "components/Files/IFileInfo"; import {FC, useEffect, useState} from "react" import Utils from "services/Utils"; import { - HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel, AccountDataDto + HomeworkViewModel, ActionOptions, HomeworkTaskViewModel, PostTaskViewModel, AccountDataDto, + Group } from "@/api"; import ApiSingleton from "../../api/ApiSingleton"; import Tags from "../Common/Tags"; @@ -65,6 +66,7 @@ const CourseHomeworkEditor: FC<{ waitingNewFilesCount: number, deletingFilesIds: number[]) => void; onGroupsUpdate: () => void; + groups: Group[]; }> = (props) => { const homework = props.homeworkAndFilesInfo.homework const isNewHomework = homework.id! < 0 @@ -312,6 +314,7 @@ const CourseHomeworkEditor: FC<{ selectedGroupId={selectedGroupId} choiceDisabled={!isNewHomework} onGroupsUpdate={props.onGroupsUpdate} + groups={props.groups} /> {tags.includes(TestTag) && @@ -421,6 +424,7 @@ const CourseHomeworkExperimental: FC<{ waitingNewFilesCount: number, deletingFilesIds: number[]) => void; onGroupsUpdate: () => void; + groups: Group[]; }> = (props) => { const {homework, filesInfo} = props.homeworkAndFilesInfo const deferredTasks = homework.tasks!.filter(t => t.isDeferred!) @@ -442,6 +446,7 @@ const CourseHomeworkExperimental: FC<{ }} onStartProcessing={props.onStartProcessing} onGroupsUpdate={props.onGroupsUpdate} + groups={props.groups} /> return