diff --git a/src/app/api/models/unit.ts b/src/app/api/models/unit.ts index b27159832e..14bbb1e083 100644 --- a/src/app/api/models/unit.ts +++ b/src/app/api/models/unit.ts @@ -63,6 +63,10 @@ export class Unit extends Entity { extensionWeeksOnResubmitRequest: number; allowStudentChangeTutorial: boolean; + credit_points: number; + prerequisites: string; + corequisites: string; + public readonly learningOutcomesCache: EntityCache = new EntityCache(); public readonly tutorialStreamsCache: EntityCache = @@ -231,8 +235,12 @@ export class Unit extends Entity { return Math.round((startToNow / totalDuration) * 100); } - public rolloverTo(body: {new_unit_code?: string, start_date: Date; end_date: Date}): Observable; - public rolloverTo(body: {new_unit_code?: string, teaching_period_id: number}): Observable; + public rolloverTo(body: { + new_unit_code?: string; + start_date: Date; + end_date: Date; + }): Observable; + public rolloverTo(body: {new_unit_code?: string; teaching_period_id: number}): Observable; public rolloverTo(body: any): Observable { const unitService = AppInjector.get(UnitService); diff --git a/src/app/api/services/unit.service.ts b/src/app/api/services/unit.service.ts index 4b62536012..cb1feb8cad 100644 --- a/src/app/api/services/unit.service.ts +++ b/src/app/api/services/unit.service.ts @@ -1,14 +1,23 @@ -import { Injectable } from '@angular/core'; -import { HttpClient } from '@angular/common/http'; -import { GroupSetService, LearningOutcomeService, TaskOutcomeAlignmentService, TeachingPeriodService, TutorialService, TutorialStreamService, Unit, UserService } from 'src/app/api/models/doubtfire-model'; -import { CachedEntityService, Entity, EntityMapping } from 'ngx-entity-service'; +import {Injectable} from '@angular/core'; +import {HttpClient} from '@angular/common/http'; +import { + GroupSetService, + LearningOutcomeService, + TaskOutcomeAlignmentService, + TeachingPeriodService, + TutorialService, + TutorialStreamService, + Unit, + UserService, +} from 'src/app/api/models/doubtfire-model'; +import {CachedEntityService, Entity, EntityMapping} from 'ngx-entity-service'; import API_URL from 'src/app/config/constants/apiURL'; -import { UnitRoleService } from './unit-role.service'; -import { AppInjector } from 'src/app/app-injector'; -import { TaskDefinitionService } from './task-definition.service'; -import { GroupService } from './group.service'; -import { Observable } from 'rxjs'; -import { DoubtfireConstants } from 'src/app/config/constants/doubtfire-constants'; +import {UnitRoleService} from './unit-role.service'; +import {AppInjector} from 'src/app/app-injector'; +import {TaskDefinitionService} from './task-definition.service'; +import {GroupService} from './group.service'; +import {Observable} from 'rxjs'; +import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; export type IloStats = { median: number; @@ -32,7 +41,7 @@ export class UnitService extends CachedEntityService { private taskDefinitionService: TaskDefinitionService, private taskOutcomeAlignmentService: TaskOutcomeAlignmentService, private groupSetService: GroupSetService, - private groupService: GroupService + private groupService: GroupService, ) { super(http, API_URL); @@ -50,7 +59,7 @@ export class UnitService extends CachedEntityService { toEntityFn: (data: object, jsonKey: string, entity: Unit) => { const unitRoleService = AppInjector.get(UnitRoleService); unitRoleService.cache.get(data[jsonKey]); - } + }, }, { keys: 'staff', @@ -58,10 +67,10 @@ export class UnitService extends CachedEntityService { const unitRoleService = AppInjector.get(UnitRoleService); // Add staff entity.staffCache.clear(); - data[key]?.forEach(staff => { + data[key]?.forEach((staff) => { entity.staffCache.add(unitRoleService.buildInstance(staff)); }); - } + }, }, { keys: ['mainConvenor', 'main_convenor_id'], @@ -72,7 +81,7 @@ export class UnitService extends CachedEntityService { }, toJsonFn: (unit: Unit, key: string) => { return unit.mainConvenor?.id; - } + }, }, { keys: ['mainConvenorUser', 'main_convenor_user_id'], @@ -81,20 +90,22 @@ export class UnitService extends CachedEntityService { }, toJsonFn: (unit: Unit, key: string) => { return unit.mainConvenor?.user.id; - } + }, }, { keys: ['teachingPeriod', 'teaching_period_id'], toEntityFn: (data, key, entity) => { - if ( data['teaching_period_id'] ) { + if (data['teaching_period_id']) { const teachingPeriod = this.teachingPeriodService.cache.get(data['teaching_period_id']); teachingPeriod?.unitsCache.add(entity); return teachingPeriod; - } else { return undefined; } + } else { + return undefined; + } }, toJsonFn: (entity: Unit, key: string) => { return entity.teachingPeriod ? entity.teachingPeriod.id : undefined; - } + }, }, { keys: 'startDate', @@ -103,7 +114,7 @@ export class UnitService extends CachedEntityService { }, toJsonFn: (entity, key) => { return entity.startDate.toISOString().slice(0, 10); - } + }, }, { keys: 'endDate', @@ -112,7 +123,7 @@ export class UnitService extends CachedEntityService { }, toJsonFn: (entity, key) => { return entity.endDate.toISOString().slice(0, 10); - } + }, }, { keys: 'portfolioAutoGenerationDate', @@ -121,7 +132,7 @@ export class UnitService extends CachedEntityService { }, toJsonFn: (entity, key) => { return entity.portfolioAutoGenerationDate?.toISOString().slice(0, 10); - } + }, }, 'assessmentEnabled', 'overseerImageId', @@ -135,37 +146,43 @@ export class UnitService extends CachedEntityService { { keys: 'ilos', toEntityOp: (data: object, key: string, unit: Unit) => { - data[key]?.forEach(ilo => { + data[key]?.forEach((ilo) => { unit.learningOutcomesCache.getOrCreate(ilo['id'], this.learningOutcomeService, ilo); }); - } + }, }, { keys: 'tutorialStreams', toEntityOp: (data, key, entity) => { data['tutorial_streams'].forEach((streamJson: object) => { - entity.tutorialStreamsCache.add(this.tutorialStreamService.buildInstance(streamJson, {constructorParams: entity})); + entity.tutorialStreamsCache.add( + this.tutorialStreamService.buildInstance(streamJson, {constructorParams: entity}), + ); }); - } + }, }, { keys: 'tutorials', toEntityOp: (data, key, entity) => { data['tutorials'].forEach((tutorialJson: object) => { if (tutorialJson) { - entity.tutorialsCache.add(this.tutorialService.buildInstance(tutorialJson, {constructorParams: entity})); + entity.tutorialsCache.add( + this.tutorialService.buildInstance(tutorialJson, {constructorParams: entity}), + ); } }); - } + }, }, // 'tutorialEnrolments', - map to tutorial enrolments { keys: 'groupSets', toEntityOp: (data, key, unit) => { data[key]?.forEach((groupSetJson: object) => { - unit.groupSetsCache.add(this.groupSetService.buildInstance(groupSetJson, {constructorParams: unit})); + unit.groupSetsCache.add( + this.groupSetService.buildInstance(groupSetJson, {constructorParams: unit}), + ); }); - } + }, }, { keys: 'groups', @@ -174,17 +191,22 @@ export class UnitService extends CachedEntityService { const group = this.groupService.buildInstance(groupJson, {constructorParams: unit}); group.groupSet.groupsCache.add(group); }); - } + }, }, { keys: 'taskDefinitions', toEntityOp: (data, key, unit) => { var seq: number = 0; data['task_definitions'].forEach((taskDefinitionJson: object) => { - const td = unit.taskDefinitionCache.getOrCreate(taskDefinitionJson['id'], this.taskDefinitionService, taskDefinitionJson, {constructorParams: unit}); + const td = unit.taskDefinitionCache.getOrCreate( + taskDefinitionJson['id'], + this.taskDefinitionService, + taskDefinitionJson, + {constructorParams: unit}, + ); td.seq = seq++; }); - } + }, }, { keys: ['draftTaskDefinition', 'draft_task_definition_id'], @@ -193,22 +215,22 @@ export class UnitService extends CachedEntityService { }, toJsonFn: (unit: Unit, key: string) => { return unit.draftTaskDefinition?.id; - } + }, }, { keys: 'taskOutcomeAlignments', toEntityOp: (data: object, jsonKey: string, unit: Unit) => { - data[jsonKey].forEach( (alignment) => { + data[jsonKey].forEach((alignment) => { unit.taskOutcomeAlignmentsCache.getOrCreate( alignment['id'], this.taskOutcomeAlignmentService, alignment, { - constructorParams: unit - } + constructorParams: unit, + }, ); }); - } + }, }, // 'groupMemberships', - map to group memberships ); @@ -237,7 +259,7 @@ export class UnitService extends CachedEntityService { 'draftTaskDefinition', 'allowStudentExtensionRequests', 'extensionWeeksOnResubmitRequest', - 'allowStudentChangeTutorial' + 'allowStudentChangeTutorial', ); } @@ -281,7 +303,7 @@ export class UnitService extends CachedEntityService { } getUnitByCode(unitCode: string): Observable { - const url = `${API_URL}/units/${unitCode}`; + const url = `${API_URL}/units/code/${unitCode}`; return this.http.get(url); } @@ -289,5 +311,4 @@ export class UnitService extends CachedEntityService { const url = `${API_URL}/units/`; return this.http.get(url); } - } diff --git a/src/app/courseflow/common/credit-points-summary/credit-points-summary.component.html b/src/app/courseflow/common/credit-points-summary/credit-points-summary.component.html new file mode 100644 index 0000000000..67814c9e05 --- /dev/null +++ b/src/app/courseflow/common/credit-points-summary/credit-points-summary.component.html @@ -0,0 +1,43 @@ +
+ +
+
+ school + Credit Points +
+
+ {{ currentPoints }} + / + {{ totalPoints }} + CP +
+
+
+
+
+ + +
+ school + {{ currentPoints }}/{{ totalPoints }} CP +
+ + +
+
+ school +
+ {{ currentPoints }}/{{ totalPoints }} + Credit Points +
+
+
+
+
+
+
diff --git a/src/app/courseflow/common/credit-points-summary/credit-points-summary.component.scss b/src/app/courseflow/common/credit-points-summary/credit-points-summary.component.scss new file mode 100644 index 0000000000..306db927b4 --- /dev/null +++ b/src/app/courseflow/common/credit-points-summary/credit-points-summary.component.scss @@ -0,0 +1,221 @@ +.credit-points-summary { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + border-radius: 8px; + transition: all 0.3s ease; + + // Status colors + &.early { + --status-color: #f59e0b; + --status-bg: #fef3c7; + --status-border: #fcd34d; + } + + &.moderate { + --status-color: #3b82f6; + --status-bg: #dbeafe; + --status-border: #93c5fd; + } + + &.on-track { + --status-color: #10b981; + --status-bg: #d1fae5; + --status-border: #6ee7b7; + } + + &.complete { + --status-color: #059669; + --status-bg: #a7f3d0; + --status-border: #34d399; + } + + &.overloaded { + --status-color: #dc2626; + --status-bg: #fee2e2; + --status-border: #fca5a5; + } + + // Default variant + &.default .default-display { + padding: 16px; + background: white; + border: 2px solid var(--status-border); + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + + .points-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + + .credit-icon { + color: var(--status-color); + font-size: 20px; + width: 20px; + height: 20px; + } + + .points-label { + font-size: 0.875rem; + font-weight: 600; + color: #374151; + text-transform: uppercase; + letter-spacing: 0.05em; + } + } + + .points-value { + display: flex; + align-items: baseline; + gap: 4px; + margin-bottom: 12px; + + .current { + font-size: 2rem; + font-weight: 700; + color: var(--status-color); + } + + .separator { + font-size: 1.5rem; + font-weight: 500; + color: #9ca3af; + } + + .total { + font-size: 1.5rem; + font-weight: 600; + color: #6b7280; + } + + .unit { + font-size: 0.875rem; + font-weight: 500; + color: #9ca3af; + margin-left: 4px; + } + } + + .progress-bar { + width: 100%; + height: 8px; + background: #f3f4f6; + border-radius: 4px; + overflow: hidden; + + .progress-fill { + height: 100%; + background: linear-gradient( + 90deg, + var(--status-color), + color-mix(in srgb, var(--status-color), white 20%) + ); + border-radius: 4px; + transition: width 0.5s ease; + } + } + } + + // Compact variant + &.compact .compact-display { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + background: var(--status-bg); + border: 1px solid var(--status-border); + border-radius: 20px; + + .credit-icon { + color: var(--status-color); + font-size: 18px; + width: 18px; + height: 18px; + } + + .points-text { + font-weight: 600; + color: var(--status-color); + font-size: 0.875rem; + } + } + + // Header variant + &.header .header-display { + background: white; + border: 1px solid #e5e7eb; + border-radius: 8px; + overflow: hidden; + + .header-content { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + + .credit-icon { + color: var(--status-color); + font-size: 24px; + width: 24px; + height: 24px; + } + + .header-text { + display: flex; + flex-direction: column; + + .points-main { + font-size: 1.25rem; + font-weight: 700; + color: var(--status-color); + line-height: 1.2; + } + + .points-unit { + font-size: 0.75rem; + font-weight: 500; + color: #6b7280; + text-transform: uppercase; + letter-spacing: 0.05em; + } + } + } + + .header-progress { + height: 4px; + background: #f3f4f6; + + .progress-fill { + height: 100%; + background: var(--status-color); + transition: width 0.5s ease; + } + } + } + + // Responsive design + @media (max-width: 640px) { + &.default .default-display { + padding: 12px; + + .points-value { + .current { + font-size: 1.75rem; + } + + .separator, + .total { + font-size: 1.25rem; + } + } + } + + &.header .header-display .header-content { + padding: 10px 12px; + + .header-text .points-main { + font-size: 1.125rem; + } + } + } +} diff --git a/src/app/courseflow/common/credit-points-summary/credit-points-summary.component.ts b/src/app/courseflow/common/credit-points-summary/credit-points-summary.component.ts new file mode 100644 index 0000000000..b21e89b8bc --- /dev/null +++ b/src/app/courseflow/common/credit-points-summary/credit-points-summary.component.ts @@ -0,0 +1,53 @@ +import {Component, Input} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {MatIconModule} from '@angular/material/icon'; +import {MatTooltipModule} from '@angular/material/tooltip'; + +@Component({ + selector: 'credit-points-summary', + templateUrl: './credit-points-summary.component.html', + styleUrls: ['./credit-points-summary.component.scss'], + standalone: true, + imports: [CommonModule, MatIconModule, MatTooltipModule], +}) +export class CreditPointsSummaryComponent { + @Input() currentPoints: number = 0; + @Input() totalPoints: number = 24; + @Input() showIcon: boolean = true; + @Input() variant: 'default' | 'compact' | 'header' = 'default'; + + get progressPercentage(): number { + return this.totalPoints > 0 ? Math.round((this.currentPoints / this.totalPoints) * 100) : 0; + } + + get remainingPoints(): number { + return Math.max(0, this.totalPoints - this.currentPoints); + } + + get isComplete(): boolean { + return this.currentPoints >= this.totalPoints; + } + + get isOverloaded(): boolean { + return this.currentPoints > this.totalPoints; + } + + get statusClass(): string { + if (this.isOverloaded) return 'overloaded'; + if (this.isComplete) return 'complete'; + const percentage = this.progressPercentage; + if (percentage >= 75) return 'on-track'; + if (percentage >= 50) return 'moderate'; + return 'early'; + } + + get tooltipText(): string { + if (this.isOverloaded) { + return `Overloaded by ${this.currentPoints - this.totalPoints} credit points`; + } + if (this.isComplete) { + return 'Course requirements completed'; + } + return `${this.remainingPoints} credit points remaining`; + } +} diff --git a/src/app/courseflow/common/unit-card/unit-card.component.html b/src/app/courseflow/common/unit-card/unit-card.component.html index d69ca3d12a..3a48544331 100644 --- a/src/app/courseflow/common/unit-card/unit-card.component.html +++ b/src/app/courseflow/common/unit-card/unit-card.component.html @@ -1,5 +1,11 @@
- {{ unit.code }} - {{ unit.name }} +
+ {{ unit.code }} | Level {{ getUnitLevel() }} | {{ unit.credit_points }} points +
+ +
+ {{ unit.name }} +
@@ -23,6 +22,16 @@

Study Periods

+ + +
+ +
{ + ['trimester1', 'trimester2', 'trimester3'].forEach((trimesterKey) => { + const trimester = year[trimesterKey as keyof typeof year] as (unknown | null)[]; + if (trimester) { + trimester.forEach((unit) => { + if (unit && (unit as {credit_points?: number}).credit_points) { + totalPoints += (unit as {credit_points: number}).credit_points; + } + }); + } + }); + }); - getAvailableUnits(): Unit[] { - const allRequiredIds = new Set(this.state.allRequiredUnits.map((u) => u.id)); - return this.units.filter((unit) => !allRequiredIds.has(unit.id)); + return totalPoints; } getRemainingElectiveSlots(): number { @@ -249,7 +262,7 @@ export class CoursemapComponent implements OnInit, OnDestroy { } // eslint-disable-next-line @typescript-eslint/no-explicit-any - trackByYear(index: number, year: any): number { + trackByYear(_index: number, year: any): number { return year.year; } } diff --git a/src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.html b/src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.html new file mode 100644 index 0000000000..97fad1c886 --- /dev/null +++ b/src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.html @@ -0,0 +1,15 @@ +
+

Overload

+ + +

+ If you want to enrol in more subjects than the standard amount in a semester, you will need to + apply to overload. +

+
+ + + + + +
diff --git a/src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.scss b/src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.scss new file mode 100644 index 0000000000..3129449331 --- /dev/null +++ b/src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.scss @@ -0,0 +1,74 @@ +.overload-warning-dialog { + min-width: 400px; + padding: 24px; + + h2 { + margin: 0 0 16px 0; + font-size: 24px; + font-weight: 500; + color: #333; + } + + mat-dialog-content { + p { + font-size: 16px; + line-height: 1.5; + color: #555; + margin: 0; + } + + .overload-link { + color: #1976d2; + text-decoration: underline; + + &:hover { + color: #1565c0; + } + } + } + + mat-dialog-actions { + margin-top: 24px; + padding: 0; + gap: 12px; + + button { + min-width: 80px; + font-weight: 500; + } + } +} + +// Global dialog styling +:host ::ng-deep .overload-warning-dialog-container { + .mat-mdc-dialog-container { + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15); + } + + .mat-mdc-dialog-surface { + border-radius: 8px; + } + + .mdc-dialog__title { + color: #333; + font-size: 24px; + font-weight: 500; + } + + .mat-mdc-raised-button { + background-color: #1976d2; + + &:hover { + background-color: #1565c0; + } + } + + .mat-mdc-button { + color: #1976d2; + + &:hover { + background-color: rgba(25, 118, 210, 0.04); + } + } +} diff --git a/src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.ts b/src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.ts new file mode 100644 index 0000000000..3ca8fddad8 --- /dev/null +++ b/src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.ts @@ -0,0 +1,23 @@ +import {Component} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {MatDialogRef, MatDialogModule} from '@angular/material/dialog'; +import {MatButtonModule} from '@angular/material/button'; + +@Component({ + selector: 'f-overload-warning-dialog', + templateUrl: './overload-warning-dialog.component.html', + styleUrls: ['./overload-warning-dialog.component.scss'], + standalone: true, + imports: [CommonModule, MatDialogModule, MatButtonModule], +}) +export class OverloadWarningDialogComponent { + constructor(public dialogRef: MatDialogRef) {} + + onCancel(): void { + this.dialogRef.close(false); + } + + onOk(): void { + this.dialogRef.close(true); + } +} diff --git a/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.html b/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.html index bf3758676e..35367267a5 100644 --- a/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.html +++ b/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.html @@ -4,16 +4,42 @@
- - +
+
+ + + + + +
+ + + +
diff --git a/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.scss b/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.scss index e5dd591a8f..10748d37fd 100644 --- a/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.scss +++ b/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.scss @@ -8,6 +8,7 @@ border-radius: 8px; margin: 10px 0; box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.1); + max-width: 956px; } .trimester-heading { @@ -28,11 +29,89 @@ } .slots-container { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; // Allow container to shrink +} + +.slots-wrapper { display: flex; flex-direction: row; flex-wrap: nowrap; gap: 10px; - flex: 1; + overflow-x: auto; + padding: 2px; // Small padding to prevent scrollbar from hiding content + + // Custom scrollbar styling + &::-webkit-scrollbar { + height: 6px; + } + + &::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; + } + + &::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; + + &:hover { + background: #a8a8a8; + } + } +} + +.slot-item { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + + // Dynamic width based on content with reasonable bounds + width: fit-content; + max-width: 200px; + + // Dynamic height based on content + height: auto; + min-height: fit-content; + + // Prevent content overflow + overflow: hidden; + word-wrap: break-word; +} + +.add-slot-button { + flex-shrink: 0; + align-self: center; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } +} + +.remove-slot-button { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + color: #f44336; + transition: all 0.2s ease; + + &.visible { + opacity: 1; + visibility: visible; + } + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } } .delete-button { diff --git a/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.ts b/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.ts index 2e36aac81e..4d36ae5d2f 100644 --- a/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.ts +++ b/src/app/courseflow/states/coursemap/directives/trimester-editor/trimester-editor.component.ts @@ -2,9 +2,11 @@ import {Component, Input, Output, EventEmitter} from '@angular/core'; import {CommonModule} from '@angular/common'; import {MatIconModule} from '@angular/material/icon'; import {MatButtonModule} from '@angular/material/button'; +import {MatDialog} from '@angular/material/dialog'; import {CourseUnit} from '../../../../models/course-map.models'; import {CourseMapStateService} from '../../../../services/course-map-state.service'; import {UnitSlotComponent} from '../unit-slot/unit-slot.component'; +import {OverloadWarningDialogComponent} from './overload-warning-dialog/overload-warning-dialog.component'; @Component({ selector: 'trimester-editor', @@ -19,15 +21,23 @@ export class TrimesterEditorComponent { @Input() yearIndex!: number; @Input() trimesterIndex!: number; @Input() stateService!: CourseMapStateService; + // eslint-disable-next-line @typescript-eslint/no-explicit-any @Output() dropEvent = new EventEmitter(); @Output() deleteTrimester = new EventEmitter(); - readonly slotIndices = [0, 1, 2, 3]; + private totalSlots = 4; + + constructor(private dialog: MatDialog) {} + + get slotIndices(): number[] { + return Array.from({length: this.totalSlots}, (_, i) => i); + } getTrimesterNumber(): number { return this.stateService.getTrimesterNumber(this.trimesterKey); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any onSlotDrop(event: any): void { this.dropEvent.emit(event); } @@ -40,6 +50,42 @@ export class TrimesterEditorComponent { this.stateService.removeUnitFromSlot(this.yearIndex, this.trimesterKey, slotIndex); } + onAddSlot(): void { + const dialogRef = this.dialog.open(OverloadWarningDialogComponent, { + width: '450px', + disableClose: false, + panelClass: 'overload-warning-dialog-container', + }); + + dialogRef.afterClosed().subscribe((result) => { + if (result === true) { + // User clicked OK, proceed with adding the slot + this.totalSlots++; + while (this.trimester.length < this.totalSlots) { + this.trimester.push(null); + } + } + // If result is false or undefined (canceled), do nothing + }); + } + + onRemoveSlot(slotIndex: number): void { + if (slotIndex >= 4 && this.totalSlots > 4) { + this.stateService.removeUnitFromSlot(this.yearIndex, this.trimesterKey, slotIndex); + // Shift all units after this slot one position left + for (let i = slotIndex; i < this.trimester.length - 1; i++) { + this.trimester[i] = this.trimester[i + 1]; + } + + this.trimester.pop(); + this.totalSlots--; + } + } + + canRemoveSlot(slotIndex: number): boolean { + return slotIndex >= 4; + } + trackBySlotIndex(index: number): number { return index; } diff --git a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.scss b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.scss index 000e3a8630..9729510a9f 100644 --- a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.scss +++ b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.scss @@ -37,4 +37,8 @@ align-items: center; gap: 8px; font-weight: 800; + + mat-icon { + flex-shrink: 0; + } } diff --git a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.ts b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.ts index 8b1c228460..39c59f5c13 100644 --- a/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.ts +++ b/src/app/courseflow/states/coursemap/directives/unit-search/unit-search.component.ts @@ -1,4 +1,4 @@ -import {Component, Input, Output, EventEmitter} from '@angular/core'; +import {Component} from '@angular/core'; import {CommonModule} from '@angular/common'; import {FormsModule} from '@angular/forms'; import {MatFormFieldModule} from '@angular/material/form-field'; @@ -6,6 +6,9 @@ import {MatInputModule} from '@angular/material/input'; import {MatButtonModule} from '@angular/material/button'; import {MatIconModule} from '@angular/material/icon'; import {Unit} from 'src/app/api/models/doubtfire-model'; +import {UnitService} from 'src/app/api/services/unit.service'; +import {HttpErrorResponse} from '@angular/common/http'; +import {CourseMapStateService} from '../../../../services/course-map-state.service'; @Component({ selector: 'unit-search', @@ -22,12 +25,14 @@ import {Unit} from 'src/app/api/models/doubtfire-model'; ], }) export class UnitSearchComponent { - @Input() availableUnits!: Unit[]; - @Output() unitAdded = new EventEmitter(); - unitCode = ''; errorMessage: string | null = null; + constructor( + private unitService: UnitService, + private courseMapStateService: CourseMapStateService, + ) {} + onSubmit(): void { if (!this.unitCode) { this.errorMessage = 'Please enter a unit code'; @@ -35,14 +40,26 @@ export class UnitSearchComponent { } const trimmedCode = this.unitCode.trim().toUpperCase(); - const foundUnit = this.availableUnits.find((unit) => unit.code === trimmedCode); + this.errorMessage = null; - if (foundUnit) { - this.unitAdded.emit(foundUnit); - this.unitCode = ''; - this.errorMessage = null; - } else { - this.errorMessage = `Unit code ${trimmedCode} not found in available units`; - } + this.unitService.getUnitByCode(trimmedCode).subscribe({ + next: (foundUnit) => { + if (foundUnit) { + const added = this.courseMapStateService.addElectiveUnit(foundUnit); + if (added) { + this.unitCode = ''; + this.errorMessage = null; + } else { + this.errorMessage = `Unit ${trimmedCode} cannot be added. It may already be on the map or is a required unit.`; + } + } else { + this.errorMessage = `Unit code ${trimmedCode} not found`; + } + }, + error: (err: HttpErrorResponse) => { + this.errorMessage = `Unit code ${trimmedCode} not found`; + console.log(err.statusText); + }, + }); } } diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 8f665960c5..0b9b3663e0 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -216,7 +216,6 @@ import {UnitAnalyticsComponent} from './units/states/analytics/unit-analytics-ro import {FileDropComponent} from './common/file-drop/file-drop.component'; import {UnitTaskEditorComponent} from './units/states/edit/directives/unit-tasks-editor/unit-task-editor.component'; import {FUsersComponent} from './admin/states/f-users/f-users.component'; -import {CoursemapComponent} from './courseflow/states/coursemap/coursemap.component'; import {CreateNewUnitModal} from './admin/modals/create-new-unit-modal/create-new-unit-modal.component'; import {CreateNewUnitModalContentComponent} from './admin/modals/create-new-unit-modal/create-new-unit-modal-content.component';