diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 99364ae450..7d8b45bf69 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -2197,6 +2197,12 @@ paths: schema: type: array items: {type: string} + - name: supervisorIds + in: query + required: false + schema: + type: array + items: {type: string, format: uuid} - name: sortBy in: query required: false diff --git a/src/main/java/de/tum/cit/aet/job/dto/ProfessorJobsFilterDTO.java b/src/main/java/de/tum/cit/aet/job/dto/ProfessorJobsFilterDTO.java index d07a9eefea..a944bcf13d 100644 --- a/src/main/java/de/tum/cit/aet/job/dto/ProfessorJobsFilterDTO.java +++ b/src/main/java/de/tum/cit/aet/job/dto/ProfessorJobsFilterDTO.java @@ -1,11 +1,14 @@ package de.tum.cit.aet.job.dto; import java.util.List; +import java.util.UUID; /** - * Filter DTO for retrieving jobs created by a specific professor. - * Encapsulates optional filters for job title and state. + * Filter DTO for retrieving jobs visible to a member of a research group. * - * @param states optional filter for multiple job states + * @param states optional filter for multiple job states + * @param supervisorIds optional filter restricting jobs to a list of + * supervising-professor user ids. {@code null} or + * empty means "all supervisors in the research group". */ -public record ProfessorJobsFilterDTO(List states) {} +public record ProfessorJobsFilterDTO(List states, List supervisorIds) {} diff --git a/src/main/java/de/tum/cit/aet/job/repository/JobRepository.java b/src/main/java/de/tum/cit/aet/job/repository/JobRepository.java index 79e71fe7f1..e13289e906 100644 --- a/src/main/java/de/tum/cit/aet/job/repository/JobRepository.java +++ b/src/main/java/de/tum/cit/aet/job/repository/JobRepository.java @@ -26,11 +26,13 @@ @Repository public interface JobRepository extends DocApplyJpaRepository { /** - * Finds all jobs that belong to a given research group, with optional state and title/professor search filters. - * Results are paginated. + * Finds all jobs that belong to a given research group, with optional state, + * supervisor, and title/professor search filters. Results are paginated. * * @param researchGroupId the research group ID to filter by * @param states the optional list of job states to include + * @param supervisorIds the optional list of supervising-professor user ids; + * {@code null}/empty means all supervisors * @param searchQuery the optional search string for job title or professor name * @param pageable the pagination configuration * @return a page of matching jobs @@ -50,6 +52,7 @@ public interface JobRepository extends DocApplyJpaRepository { FROM Job j WHERE j.researchGroup.researchGroupId = :researchGroupId AND (:states IS NULL OR j.state IN :states) + AND (:supervisorIds IS NULL OR j.supervisingProfessor.userId IN :supervisorIds) AND (:searchQuery IS NULL OR j.title LIKE CONCAT('%', :searchQuery, '%') OR CONCAT(j.supervisingProfessor.firstName, ' ', j.supervisingProfessor.lastName) LIKE CONCAT('%', :searchQuery, '%') @@ -59,6 +62,7 @@ j.title LIKE CONCAT('%', :searchQuery, '%') OR Page findAllJobsByResearchGroup( @Param("researchGroupId") UUID researchGroupId, @Param("states") List states, + @Param("supervisorIds") List supervisorIds, @Param("searchQuery") String searchQuery, Pageable pageable ); diff --git a/src/main/java/de/tum/cit/aet/job/service/JobService.java b/src/main/java/de/tum/cit/aet/job/service/JobService.java index 231f80229f..8685a76942 100644 --- a/src/main/java/de/tum/cit/aet/job/service/JobService.java +++ b/src/main/java/de/tum/cit/aet/job/service/JobService.java @@ -376,8 +376,12 @@ public Page getJobsForCurrentResearchGroup( if (professorJobsFilterDTO.states() != null && !professorJobsFilterDTO.states().isEmpty()) { enumStates = professorJobsFilterDTO.states().stream().map(JobState::fromValue).filter(Objects::nonNull).toList(); } + List supervisorIds = + professorJobsFilterDTO.supervisorIds() == null || professorJobsFilterDTO.supervisorIds().isEmpty() + ? null + : professorJobsFilterDTO.supervisorIds(); String normalizedSearchQuery = StringUtil.normalizeSearchQuery(searchQuery); - return jobRepository.findAllJobsByResearchGroup(researchGroupId, enumStates, normalizedSearchQuery, pageable); + return jobRepository.findAllJobsByResearchGroup(researchGroupId, enumStates, supervisorIds, normalizedSearchQuery, pageable); } /** diff --git a/src/main/java/de/tum/cit/aet/usermanagement/dto/ProfessorDTO.java b/src/main/java/de/tum/cit/aet/usermanagement/dto/ProfessorDTO.java index dc081b7cba..23ba7ae645 100644 --- a/src/main/java/de/tum/cit/aet/usermanagement/dto/ProfessorDTO.java +++ b/src/main/java/de/tum/cit/aet/usermanagement/dto/ProfessorDTO.java @@ -6,29 +6,29 @@ public record ProfessorDTO(String firstName, String lastName, String email, String researchGroupName, String researchGroupWebsite) { /** - * Converts a {@link Job}'s supervising professor to a {@link ProfessorDTO}, - * pulling the research group fields from the job itself rather than from the - * professor's membership list (a professor may belong to multiple groups). + * Converts a {@link Job}'s supervising professor to a {@link ProfessorDTO}, pulling the research group + * fields from the job itself rather than from the professor's membership list (a professor may belong to + * multiple groups). * - * @param job the job whose supervising professor is being represented + *

The job retains its research group even after the supervising professor + * is anonymised, so this is the safe choice for application/evaluation flows + * where the original research-group context still matters. Research-group + * fields are left null when the job has no research group (e.g. the + * anonymised "deleted user" sentinel after retention runs), so evaluation + * keeps working after a professor leaves. + * + * @param job the job whose supervising professor and research group should be used * @return the corresponding {@link ProfessorDTO} - * @throws IllegalStateException if the job has no supervising professor or no research group */ public static ProfessorDTO fromJob(Job job) { User professor = job.getSupervisingProfessor(); - if (professor == null) { - throw new IllegalStateException("Job has no supervising professor"); - } ResearchGroup researchGroup = job.getResearchGroup(); - if (researchGroup == null) { - throw new IllegalStateException("Research group is null"); - } return new ProfessorDTO( professor.getFirstName(), professor.getLastName(), professor.getEmail(), - researchGroup.getName(), - researchGroup.getWebsite() + researchGroup != null ? researchGroup.getName() : null, + researchGroup != null ? researchGroup.getWebsite() : null ); } } diff --git a/src/main/webapp/app/generated/api/job-resource-api.ts b/src/main/webapp/app/generated/api/job-resource-api.ts index d5dd1033a9..402d0f04c6 100644 --- a/src/main/webapp/app/generated/api/job-resource-api.ts +++ b/src/main/webapp/app/generated/api/job-resource-api.ts @@ -193,11 +193,12 @@ export class JobResourceApi { * @param pageSize * @param pageNumber * @param states + * @param supervisorIds * @param sortBy * @param direction * @param searchQuery */ - getJobsForCurrentResearchGroup(pageSize?: number, pageNumber?: number, states?: Array, sortBy?: string, direction?: 'ASC' | 'DESC', searchQuery?: string): Observable { + getJobsForCurrentResearchGroup(pageSize?: number, pageNumber?: number, states?: Array, supervisorIds?: Array, sortBy?: string, direction?: 'ASC' | 'DESC', searchQuery?: string): Observable { const queryParams = new URLSearchParams(); if (pageSize !== undefined && pageSize !== null) { queryParams.set('pageSize', String(pageSize)); @@ -208,6 +209,9 @@ export class JobResourceApi { if (states !== undefined && states !== null) { states.forEach(item => queryParams.append('states', String(item))); } + if (supervisorIds !== undefined && supervisorIds !== null) { + supervisorIds.forEach(item => queryParams.append('supervisorIds', String(item))); + } if (sortBy !== undefined && sortBy !== null) { queryParams.set('sortBy', String(sortBy)); } diff --git a/src/main/webapp/app/generated/api/job-resource-resources.ts b/src/main/webapp/app/generated/api/job-resource-resources.ts index dc705c7a64..4b96f7b700 100644 --- a/src/main/webapp/app/generated/api/job-resource-resources.ts +++ b/src/main/webapp/app/generated/api/job-resource-resources.ts @@ -176,6 +176,7 @@ export interface GetJobsForCurrentResearchGroupParams { pageSize?: number; pageNumber?: number; states?: Array; + supervisorIds?: Array; sortBy?: string; direction?: 'ASC' | 'DESC'; searchQuery?: string; @@ -200,6 +201,9 @@ export function getJobsForCurrentResearchGroupResource(params?: Signal searchParams.append('states', String(value))); } + if (queryParams.supervisorIds?.length) { + queryParams.supervisorIds.forEach(value => searchParams.append('supervisorIds', String(value))); + } if (queryParams.sortBy !== undefined && queryParams.sortBy !== null) { searchParams.set('sortBy', String(queryParams.sortBy)); } diff --git a/src/main/webapp/app/job/my-positions/my-positions-page.component.html b/src/main/webapp/app/job/my-positions/my-positions-page.component.html index e261de8020..3485ec0113 100644 --- a/src/main/webapp/app/job/my-positions/my-positions-page.component.html +++ b/src/main/webapp/app/job/my-positions/my-positions-page.component.html @@ -70,6 +70,12 @@

filterOptions: availableStatusLabels, shouldTranslateOptions: true, }, + { + filterId: 'supervisor', + filterLabel: 'myPositionsPage.searchFilterSortBar.filterOptions.supervisor', + filterSearchPlaceholder: 'myPositionsPage.searchFilterSortBar.filterOptions.supervisorSearchPlaceholder', + filterOptions: availableSupervisorNames(), + }, ]" [sortableFields]="sortableFields" /> diff --git a/src/main/webapp/app/job/my-positions/my-positions-page.component.ts b/src/main/webapp/app/job/my-positions/my-positions-page.component.ts index 746c46a291..2827be2ae8 100644 --- a/src/main/webapp/app/job/my-positions/my-positions-page.component.ts +++ b/src/main/webapp/app/job/my-positions/my-positions-page.component.ts @@ -23,6 +23,8 @@ import LocalizedDatePipe from '../../shared/pipes/localized-date.pipe'; import { TagComponent } from '../../shared/components/atoms/tag/tag.component'; import { CreatedJobDTO, CreatedJobDTOStateEnum } from '../../generated/model/created-job-dto'; import { JobResourceApi } from '../../generated/api/job-resource-api'; +import { ResearchGroupResourceApi } from '../../generated/api/research-group-resource-api'; +import { UserShortDTO } from '../../generated/model/user-short-dto'; @Component({ selector: 'jhi-my-positions-page', standalone: true, @@ -89,6 +91,11 @@ export class MyPositionsPageComponent { currentJobId = signal(undefined); readonly selectedStatusFilters = signal([]); + readonly selectedSupervisorIds = signal([]); + readonly availableSupervisors = signal([]); + readonly availableSupervisorNames = computed(() => + this.availableSupervisors().map(s => `${s.firstName ?? ''} ${s.lastName ?? ''}`.trim()), + ); readonly columns = computed(() => { const tpl = this.actionTemplate(); @@ -187,6 +194,7 @@ export class MyPositionsPageComponent { }); private jobApi = inject(JobResourceApi); + private researchGroupApi = inject(ResearchGroupResourceApi); private accountService = inject(AccountService); private router = inject(Router); private toastService = inject(ToastService); @@ -213,6 +221,10 @@ export class MyPositionsPageComponent { void this.loadJobs(); }); + constructor() { + void this.loadSupervisors(); + } + loadOnTableEmit(event: TableLazyLoadEvent): void { const page = Math.floor((event.first ?? 0) / (event.rows ?? this.pageSize())); const size = event.rows ?? this.pageSize(); @@ -239,6 +251,11 @@ export class MyPositionsPageComponent { const enumValues = this.mapTranslationKeysToEnumValues(filterChange.selectedValues); this.selectedStatusFilters.set(enumValues); void this.loadJobs(); + } else if (filterChange.filterId === 'supervisor') { + this.page.set(0); + const ids = this.mapSupervisorNamesToIds(filterChange.selectedValues); + this.selectedSupervisorIds.set(ids); + void this.loadJobs(); } } @@ -317,6 +334,23 @@ export class MyPositionsPageComponent { return translationKeys.map(key => keyMap.get(key) ?? key); } + private mapSupervisorNamesToIds(names: string[]): string[] { + if (names.length === 0) { + return []; + } + const byName = new Map(this.availableSupervisors().map(s => [`${s.firstName ?? ''} ${s.lastName ?? ''}`.trim(), s.userId])); + return names.map(n => byName.get(n)).filter((id): id is string => id !== undefined); + } + + private async loadSupervisors(): Promise { + try { + const supervisors = await firstValueFrom(this.researchGroupApi.getResearchGroupProfessors()); + this.availableSupervisors.set(supervisors); + } catch { + this.availableSupervisors.set([]); + } + } + private async loadJobs(): Promise { this.loading.set(true); try { @@ -329,6 +363,7 @@ export class MyPositionsPageComponent { this.pageSize(), this.page(), emptyToUndef(this.selectedStatusFilters()), + emptyToUndef(this.selectedSupervisorIds()), this.sortBy(), this.sortDirection(), this.searchQuery(), diff --git a/src/main/webapp/i18n/de/job.json b/src/main/webapp/i18n/de/job.json index 22f979e409..8ed918cf76 100644 --- a/src/main/webapp/i18n/de/job.json +++ b/src/main/webapp/i18n/de/job.json @@ -375,7 +375,9 @@ "searchFilterSortBar": { "searchText": "Stellen suchen...", "filterOptions": { - "stateSearchPlaceholder": "Suche Status..." + "stateSearchPlaceholder": "Suche Status...", + "supervisor": "Betreuung", + "supervisorSearchPlaceholder": "Suche Betreuung..." } }, "tableColumn": { diff --git a/src/main/webapp/i18n/en/job.json b/src/main/webapp/i18n/en/job.json index 8830e3e23c..9a192e446d 100644 --- a/src/main/webapp/i18n/en/job.json +++ b/src/main/webapp/i18n/en/job.json @@ -375,7 +375,9 @@ "searchFilterSortBar": { "searchText": "Search Positions...", "filterOptions": { - "stateSearchPlaceholder": "Search Status..." + "stateSearchPlaceholder": "Search Status...", + "supervisor": "Supervisor", + "supervisorSearchPlaceholder": "Search Supervisor..." } }, "tableColumn": { diff --git a/src/test/webapp/app/job/my-positions/my-positions-page.component.spec.ts b/src/test/webapp/app/job/my-positions/my-positions-page.component.spec.ts index f98ebec309..99fc2d8549 100644 --- a/src/test/webapp/app/job/my-positions/my-positions-page.component.spec.ts +++ b/src/test/webapp/app/job/my-positions/my-positions-page.component.spec.ts @@ -206,6 +206,7 @@ describe('MyPositionsPageComponent', () => { component.pageSize(), component.page(), [CreatedJobDTOStateEnum.Published], + undefined, component.sortBy(), component.sortDirection(), component.searchQuery(),