Skip to content

Commit e3dcc2e

Browse files
Program directors and areas responsibles (#76)
1 parent 8cb8c4a commit e3dcc2e

62 files changed

Lines changed: 2750 additions & 83 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Client/environments/environment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export const environment = {
33
redirect: 'https://module.aet.cit.tum.de',
44
serverUrl: 'https://module.aet.cit.tum.de',
55
keycloak: {
6-
url: ' https://keycloak.ase.in.tum.de/',
6+
url: 'https://keycloak.ase.in.tum.de',
77
realm: 'tum',
88
clientId: 'module-management'
99
}

Client/src/app/app.routes.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,10 @@ import { SimilarModulesPage } from './pages/similar-modules/similar-modules.comp
1313
import { AccountLayoutComponent } from './pages/account-management/account-layout/account-layout.component';
1414
import { AccountInformationComponent } from './pages/account-management/account-information/account-information.component';
1515
import { AccountPasskeysComponent } from './pages/account-management/passkeys/account-passkeys.component';
16-
import { AdminUsersPageComponent } from './pages/admin/users/admin-users-page.component';
17-
16+
import { UsersPageComponent } from './pages/admin/users/users-page.component';
17+
import { AllDegreeProgramsPageComponent } from './pages/admin/degree-programs/all-degree-programs-page.component';
18+
import { DegreeProgramDetailsPageComponent } from './pages/admin/degree-programs/degree-program-details-page.component';
19+
import { AllSpecializationsPageComponent } from './pages/admin/degree-program-specializations/all-specializations-page.component';
1820
export const routes: Routes = [
1921
{ path: '', component: IndexComponent },
2022
{
@@ -52,7 +54,10 @@ export const routes: Routes = [
5254
path: 'admin',
5355
canActivate: [AuthGuard, AdminGuard],
5456
children: [
55-
{ path: 'users', component: AdminUsersPageComponent },
57+
{ path: 'users', component: UsersPageComponent },
58+
{ path: 'degree-programs/specializations', component: AllSpecializationsPageComponent },
59+
{ path: 'degree-programs/:id', component: DegreeProgramDetailsPageComponent },
60+
{ path: 'degree-programs', component: AllDegreeProgramsPageComponent },
5661
{ path: '', redirectTo: 'users', pathMatch: 'full' }
5762
]
5863
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { Injectable, signal } from '@angular/core';
2+
3+
/** Segment-specific breadcrumb labels. Pages set the relevant signal when they load data and set to null on destroy. */
4+
@Injectable({ providedIn: 'root' })
5+
export class BreadcrumbLabelsService {
6+
/** Degree program details page: program name. */
7+
readonly degreeProgramName = signal<string | null>(null);
8+
/** Proposal/view segment: module title (e.g. from latestModuleVersion.titleEng). */
9+
readonly proposalTitle = signal<string | null>(null);
10+
/** Version segment: e.g. "Version 2" from moduleVersion.version. */
11+
readonly versionLabel = signal<string | null>(null);
12+
/** Feedback view segment: e.g. module title from the feedback's module version. */
13+
readonly feedbackLabel = signal<string | null>(null);
14+
}

Client/src/app/components/breadcrumb/breadcrumb.component.ts

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Router, RouterModule, NavigationEnd } from '@angular/router';
33
import { filter } from 'rxjs/operators';
44
import { BreadcrumbModule } from 'primeng/breadcrumb';
55
import type { MenuItem } from 'primeng/api';
6-
import { SecurityStore } from '../../core/security/security-store.service';
6+
import { BreadcrumbLabelsService } from './breadcrumb-labels.service';
77

88
@Component({
99
selector: 'app-breadcrumb',
@@ -13,7 +13,7 @@ import { SecurityStore } from '../../core/security/security-store.service';
1313
})
1414
export class BreadcrumbComponent {
1515
private router = inject(Router);
16-
private securityStore = inject(SecurityStore);
16+
private breadcrumbLabels = inject(BreadcrumbLabelsService);
1717

1818
private url = signal(this.router.url);
1919

@@ -27,15 +27,49 @@ export class BreadcrumbComponent {
2727

2828
showBreadcrumb = computed(() => {
2929
const u = this.url();
30-
return u.startsWith('/proposals') || u.startsWith('/feedbacks');
30+
return u.startsWith('/proposals') || u.startsWith('/feedbacks') || u.startsWith('/admin');
3131
});
3232

3333
private buildItems(url: string): MenuItem[] {
3434
if (url.startsWith('/proposals')) return this.buildProposalItems(url);
3535
if (url.startsWith('/feedbacks')) return this.buildFeedbackItems(url);
36+
if (url.startsWith('/admin')) return this.buildAdminItems(url);
3637
return [];
3738
}
3839

40+
private buildAdminItems(url: string): MenuItem[] {
41+
const segments = url.split('/').filter(Boolean); // ['admin', 'degree-programs', ...]
42+
const items: MenuItem[] = [];
43+
44+
if (segments.length < 2) return items;
45+
46+
if (segments[1] === 'users') {
47+
items.push({ label: 'Users', routerLink: ['/admin/users'] });
48+
return items;
49+
}
50+
51+
if (segments[1] === 'degree-programs') {
52+
items.push({ label: 'Degree Programs', routerLink: ['/admin/degree-programs'] });
53+
if (segments.length <= 2) return items;
54+
55+
if (segments[2] === 'specializations') {
56+
items.push({ label: 'All areas of specializations', routerLink: ['/admin/degree-programs/specializations'] });
57+
return items;
58+
}
59+
60+
// degree-programs/:id (program details page) – label from details page when it loads the program
61+
const programId = segments[2];
62+
const name = (this.breadcrumbLabels.degreeProgramName() ?? '').trim() || `Program ${programId}`;
63+
items.push({
64+
label: name,
65+
routerLink: ['/admin/degree-programs', programId]
66+
});
67+
return items;
68+
}
69+
70+
return items;
71+
}
72+
3973
private buildProposalItems(url: string): MenuItem[] {
4074
const segments = url.split('/').filter(Boolean); // ['proposals', ...]
4175
const items: MenuItem[] = [];
@@ -53,17 +87,19 @@ export class BreadcrumbComponent {
5387

5488
if (segments[1] === 'view' && segments[2]) {
5589
const proposalId = segments[2];
90+
const proposalLabel = (this.breadcrumbLabels.proposalTitle() ?? '').trim() || `Proposal ${proposalId}`;
5691
items.push({
57-
label: 'Proposal ' + proposalId,
92+
label: proposalLabel,
5893
routerLink: ['/proposals/view', proposalId]
5994
});
6095

6196
if (segments.length <= 3) return items;
6297

6398
if (segments[3] === 'version' && segments[4]) {
6499
const versionId = segments[4];
100+
const versionSegmentLabel = (this.breadcrumbLabels.versionLabel() ?? '').trim() || `Version ${versionId}`;
65101
items.push({
66-
label: 'Version ' + versionId,
102+
label: versionSegmentLabel,
67103
routerLink: ['/proposals/view', proposalId, 'version', versionId]
68104
});
69105

@@ -97,7 +133,8 @@ export class BreadcrumbComponent {
97133
}
98134

99135
if (segments[1] === 'view' && segments[2]) {
100-
items.push({ label: 'Feedback ' + segments[2], routerLink: ['/feedbacks/view', segments[2]] });
136+
const label = (this.breadcrumbLabels.feedbackLabel() ?? '').trim() || `Feedback ${segments[2]}`;
137+
items.push({ label, routerLink: ['/feedbacks/view', segments[2]] });
101138
}
102139

103140
if (segments[3] === 'overlap' && segments[4]) {

Client/src/app/components/create-edit-base/create-edit-base.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -511,7 +511,7 @@ <h1 class="text-2xl mb-8">{{ moduleVersionDto()?.titleEng ? 'Edit Proposal for '
511511

512512
<div class="flex justify-between">
513513
<p-button (onClick)="goBack()" severity="secondary" [outlined]="true">Cancel</p-button>
514-
<p-button type="submit" severity="contrast" [disabled]="proposalForm.invalid || loading">
514+
<p-button type="submit" severity="contrast" [disabled]="proposalForm.invalid || loading()">
515515
{{ loading() ? 'Saving...' : moduleVersionDto() ? 'Update Proposal' : 'Create Proposal' }}
516516
</p-button>
517517
</div>

Client/src/app/components/side-bar/side-bar.component.html

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,14 @@
99
[style]="{ width: '100%' }"
1010
styleClass="sidebar-btn justify-start"
1111
/>
12+
<p-button
13+
label="Degree Programs"
14+
icon="pi pi-book"
15+
[routerLink]="['/admin/degree-programs']"
16+
[severity]="isActive('/admin/degree-programs') ? 'contrast' : 'secondary'"
17+
[style]="{ width: '100%' }"
18+
styleClass="sidebar-btn justify-start"
19+
/>
1220
}
1321
@if (isProfessor()) {
1422
<p-button
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<p-select
2+
[options]="userOptions()"
3+
[ngModel]="value()"
4+
(ngModelChange)="onValueChange($event)"
5+
(onFilter)="onFilter($event)"
6+
(onLazyLoad)="onLazyLoad($event)"
7+
placeholder="Select user"
8+
optionLabel="label"
9+
optionValue="value"
10+
[filter]="true"
11+
[lazy]="true"
12+
[virtualScroll]="true"
13+
[virtualScrollItemSize]="40"
14+
[loading]="loading()"
15+
[class]="styleClass()"
16+
appendTo="body"
17+
scrollHeight="15rem"
18+
>
19+
<ng-template pTemplate="footer">
20+
@if (loading()) {
21+
<div class="flex align-items-center justify-content-center">
22+
<p-progress-spinner ariaLabel="Loading users" [style]="{ width: '1.5rem', height: '1.5rem' }" strokeWidth="4" />
23+
</div>
24+
}
25+
</ng-template>
26+
</p-select>
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { Component, inject, signal, computed, input } from '@angular/core';
2+
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms';
3+
import { ProgressSpinnerModule } from 'primeng/progressspinner';
4+
import { SelectModule, SelectLazyLoadEvent } from 'primeng/select';
5+
import { Subject, debounceTime, distinctUntilChanged, firstValueFrom } from 'rxjs';
6+
import { AdminUserControllerService } from '../../core/modules/openapi';
7+
import type { UserDTO } from '../../core/modules/openapi/model/user-dto';
8+
import type { ResponsibleUserDTO } from '../../core/modules/openapi/model/responsible-user-dto';
9+
10+
const USER_PAGE_SIZE = 20;
11+
const FILTER_DEBOUNCE_MS = 500;
12+
13+
@Component({
14+
selector: 'app-users-select',
15+
standalone: true,
16+
imports: [FormsModule, ProgressSpinnerModule, SelectModule],
17+
templateUrl: './users-select.component.html',
18+
providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: UsersSelectComponent, multi: true }]
19+
})
20+
export class UsersSelectComponent implements ControlValueAccessor {
21+
private readonly adminUserControllerService = inject(AdminUserControllerService);
22+
23+
private readonly filter$ = new Subject<string>();
24+
25+
styleClass = input<string>('');
26+
/** When the selected user is not yet in the loaded list, pass this so they are displayed like other options. */
27+
selectedUser = input<ResponsibleUserDTO | null>(null);
28+
29+
constructor() {
30+
this.loadUsers();
31+
this.filter$.pipe(debounceTime(FILTER_DEBOUNCE_MS), distinctUntilChanged()).subscribe((term) => {
32+
this.searchTerm.set(term);
33+
this.loadUsers(0);
34+
});
35+
}
36+
37+
value = signal<string | null>(null);
38+
users = signal<UserDTO[]>([]);
39+
totalRecords = signal(0);
40+
loading = signal(false);
41+
searchTerm = signal('');
42+
43+
private onTouched = () => {};
44+
private onChange: (v: string | null) => void = () => {};
45+
46+
formatUserLabel(u: { firstName?: string; lastName?: string; email?: string; userId?: string }): string {
47+
return `${u.firstName ?? ''} ${u.lastName ?? ''} (${u.email ?? u.userId ?? ''})`.trim() || String(u.userId ?? '');
48+
}
49+
50+
userOptions = computed(() => {
51+
const list = this.users().map((u) => ({
52+
label: this.formatUserLabel(u),
53+
value: u.userId
54+
}));
55+
const current = this.value();
56+
if (current && !list.some((o) => o.value === current)) {
57+
const u = this.selectedUser();
58+
const label = u ? this.formatUserLabel(u) : current;
59+
return [{ label, value: current }, ...list];
60+
}
61+
return list;
62+
});
63+
64+
writeValue(v: string | null): void {
65+
this.value.set(v);
66+
}
67+
68+
registerOnChange(fn: (v: string | null) => void): void {
69+
this.onChange = fn;
70+
}
71+
72+
registerOnTouched(fn: () => void): void {
73+
this.onTouched = fn;
74+
}
75+
76+
onValueChange(v: string | null): void {
77+
this.value.set(v);
78+
this.onChange(v);
79+
this.onTouched();
80+
}
81+
82+
async loadUsers(page = 0) {
83+
if (this.loading()) return;
84+
this.loading.set(true);
85+
try {
86+
const res = await firstValueFrom(this.adminUserControllerService.getUsers(page, USER_PAGE_SIZE, this.searchTerm().trim() || undefined));
87+
this.totalRecords.set(res.totalElements ?? 0);
88+
if (page === 0) {
89+
this.users.set(res.content ?? []);
90+
} else {
91+
this.users.update((prev) => [...prev, ...(res.content ?? [])]);
92+
}
93+
} catch (e) {
94+
this.users.set([]);
95+
this.totalRecords.set(0);
96+
} finally {
97+
this.loading.set(false);
98+
}
99+
}
100+
101+
async onLazyLoad(event: SelectLazyLoadEvent): Promise<void> {
102+
if (this.totalRecords() > 0 && this.users().length >= this.totalRecords()) return;
103+
if (event.last < this.users().length) return;
104+
105+
const page = Math.floor(this.users().length / USER_PAGE_SIZE);
106+
await this.loadUsers(page);
107+
}
108+
109+
onFilter(event: { filter: string }): void {
110+
this.filter$.next(event.filter ?? '');
111+
}
112+
}

Client/src/app/core/modules/openapi/.openapi-generator/FILES

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ api.module.ts
55
api/admin-user-controller.service.ts
66
api/admin-user-controller.serviceInterface.ts
77
api/api.ts
8+
api/degree-program-specializations-controller.service.ts
9+
api/degree-program-specializations-controller.serviceInterface.ts
10+
api/degree-programs-controller.service.ts
11+
api/degree-programs-controller.serviceInterface.ts
812
api/feedback-controller.service.ts
913
api/feedback-controller.serviceInterface.ts
1014
api/module-version-controller.service.ts
@@ -18,8 +22,13 @@ encoder.ts
1822
git_push.sh
1923
index.ts
2024
model/add-module-version-dto.ts
25+
model/add-specializations-to-degree-program-dto.ts
2126
model/completion-service-request-dto.ts
2227
model/completion-service-response-dto.ts
28+
model/create-degree-program-dto.ts
29+
model/create-degree-program-specialization-dto.ts
30+
model/degree-program-dto.ts
31+
model/degree-program-specialization-dto.ts
2332
model/feedback-dto.ts
2433
model/feedback-list-item-dto.ts
2534
model/feedback.ts
@@ -36,7 +45,10 @@ model/proposal-request-dto.ts
3645
model/proposal-view-dto.ts
3746
model/proposal.ts
3847
model/proposals-compact-dto.ts
48+
model/responsible-user-dto.ts
3949
model/similar-module-dto.ts
50+
model/update-degree-program-dto.ts
51+
model/update-degree-program-specialization-dto.ts
4052
model/update-user-role-dto.ts
4153
model/user-dto.ts
4254
model/user.ts

Client/src/app/core/modules/openapi/api/api.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
export * from './admin-user-controller.service';
22
import { AdminUserControllerService } from './admin-user-controller.service';
33
export * from './admin-user-controller.serviceInterface';
4+
export * from './degree-program-specializations-controller.service';
5+
import { DegreeProgramSpecializationsControllerService } from './degree-program-specializations-controller.service';
6+
export * from './degree-program-specializations-controller.serviceInterface';
7+
export * from './degree-programs-controller.service';
8+
import { DegreeProgramsControllerService } from './degree-programs-controller.service';
9+
export * from './degree-programs-controller.serviceInterface';
410
export * from './feedback-controller.service';
511
import { FeedbackControllerService } from './feedback-controller.service';
612
export * from './feedback-controller.serviceInterface';
@@ -13,4 +19,4 @@ export * from './proposal-controller.serviceInterface';
1319
export * from './user-controller.service';
1420
import { UserControllerService } from './user-controller.service';
1521
export * from './user-controller.serviceInterface';
16-
export const APIS = [AdminUserControllerService, FeedbackControllerService, ModuleVersionControllerService, ProposalControllerService, UserControllerService];
22+
export const APIS = [AdminUserControllerService, DegreeProgramSpecializationsControllerService, DegreeProgramsControllerService, FeedbackControllerService, ModuleVersionControllerService, ProposalControllerService, UserControllerService];

0 commit comments

Comments
 (0)