From 885763857988e9a0cef335252b3cacbb6ceafe5b Mon Sep 17 00:00:00 2001 From: giangnht19 Date: Sun, 17 Aug 2025 12:16:54 +1000 Subject: [PATCH 1/5] feat: add skill summary component --- .../skills-summary-dialog.component.html | 65 ++++++++ .../skills-summary-dialog.component.scss | 143 ++++++++++++++++++ .../skills-summary-dialog.component.ts | 46 ++++++ .../common/unit-card/unit-card.component.html | 20 ++- .../common/unit-card/unit-card.component.scss | 29 ++++ .../common/unit-card/unit-card.component.ts | 21 +++ .../services/course-map-state.service.ts | 12 ++ .../states/coursemap/coursemap.component.html | 1 + .../states/coursemap/coursemap.component.ts | 34 ++++- .../course-year-editor.component.html | 1 + .../course-year-editor.component.ts | 7 +- .../trimester-editor.component.html | 2 + .../trimester-editor.component.ts | 9 ++ .../unit-slot/unit-slot.component.html | 2 + .../unit-slot/unit-slot.component.ts | 12 ++ 15 files changed, 400 insertions(+), 4 deletions(-) create mode 100644 src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.html create mode 100644 src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.scss create mode 100644 src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.ts diff --git a/src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.html b/src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.html new file mode 100644 index 000000000..54cb1dd9d --- /dev/null +++ b/src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.html @@ -0,0 +1,65 @@ +
+
+

Skills Summary

+ +
+ +
+ +
+

+ check_circle + Skills Acquired ({{ getCompletedUnits().length }} completed units) +

+
+
+
+ {{ unit.code }} - {{ unit.name }} + check_circle +
+
+ {{ unit.description }} +
+
+ No skills description available for this unit. +
+
+
+
+ + +
+

+ schedule + Skills in Development ({{ getInProgressUnits().length }} units in progress) +

+
+
+
+ {{ unit.code }} - {{ unit.name }} + schedule +
+
+ {{ unit.description }} +
+
+ No skills description available for this unit. +
+
+
+
+ + +
+ lightbulb_outline +

No Units Selected

+

Add units to your course plan to see a summary of skills you'll learn.

+
+
+ +
+ +
+
diff --git a/src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.scss b/src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.scss new file mode 100644 index 000000000..b97e3d2f0 --- /dev/null +++ b/src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.scss @@ -0,0 +1,143 @@ +.skills-summary-dialog { + max-width: 800px; + width: 100%; + max-height: 80vh; +} + +.dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 24px 0; + border-bottom: 1px solid #e0e0e0; + margin-bottom: 20px; + + h2 { + margin: 0; + color: #333; + font-weight: 500; + } + + .close-button { + color: #666; + } +} + +.dialog-content { + padding: 0 24px; + max-height: 60vh; + overflow-y: auto; +} + +.section { + margin-bottom: 30px; + + .section-title { + display: flex; + align-items: center; + margin-bottom: 15px; + color: #333; + font-size: 18px; + font-weight: 500; + + .section-icon { + margin-right: 8px; + + &.completed { + color: #4caf50; + } + + &.in-progress { + color: #ff9800; + } + } + } +} + +.units-list { + display: flex; + flex-direction: column; + gap: 16px; +} + +.unit-item { + padding: 16px; + border-radius: 8px; + border: 1px solid #e0e0e0; + background-color: #fafafa; + + &.completed { + border-left: 4px solid #4caf50; + background-color: #f3f9f3; + } + + &.in-progress { + border-left: 4px solid #ff9800; + background-color: #fff8f0; + } + + .unit-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + strong { + color: #333; + font-size: 16px; + } + + .status-icon { + font-size: 20px; + + &.completed { + color: #4caf50; + } + + &.in-progress { + color: #ff9800; + } + } + } + + .unit-description { + color: #555; + line-height: 1.6; + font-size: 14px; + } + + .no-description { + color: #999; + font-style: italic; + font-size: 14px; + } +} + +.no-units { + text-align: center; + padding: 40px 20px; + color: #666; + + .empty-icon { + font-size: 48px; + color: #ccc; + margin-bottom: 16px; + } + + h3 { + margin: 0 0 12px 0; + color: #555; + } + + p { + margin: 0; + color: #777; + } +} + +.dialog-actions { + padding: 16px 24px; + border-top: 1px solid #e0e0e0; + display: flex; + justify-content: flex-end; +} diff --git a/src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.ts b/src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.ts new file mode 100644 index 000000000..21ffbd98a --- /dev/null +++ b/src/app/courseflow/common/skills-summary-dialog/skills-summary-dialog.component.ts @@ -0,0 +1,46 @@ +import {Component, Inject} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {MatDialogModule, MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {CourseUnit} from '../../models/course-map.models'; + +interface SkillsSummaryData { + units: CourseUnit[]; +} + +@Component({ + selector: 'skills-summary-dialog', + templateUrl: './skills-summary-dialog.component.html', + styleUrls: ['./skills-summary-dialog.component.scss'], + standalone: true, + imports: [CommonModule, MatDialogModule, MatButtonModule, MatIconModule], +}) +export class SkillsSummaryDialogComponent { + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: SkillsSummaryData, + ) {} + + onClose(): void { + this.dialogRef.close(); + } + + getCompletedUnits(): CourseUnit[] { + return this.data.units.filter(unit => { + const unitWithCompletion = unit as CourseUnit & {isCompleted?: boolean}; + return unitWithCompletion.isCompleted; + }); + } + + getInProgressUnits(): CourseUnit[] { + return this.data.units.filter(unit => { + const unitWithCompletion = unit as CourseUnit & {isCompleted?: boolean}; + return !unitWithCompletion.isCompleted; + }); + } + + getAllPlacedUnits(): CourseUnit[] { + return this.data.units.filter(unit => unit !== null); + } +} 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 d69ca3d12..e77ce8073 100644 --- a/src/app/courseflow/common/unit-card/unit-card.component.html +++ b/src/app/courseflow/common/unit-card/unit-card.component.html @@ -1,12 +1,28 @@ -
+
{{ unit.code }} - {{ unit.name }} + check_circle - + + diff --git a/src/app/courseflow/common/unit-card/unit-card.component.scss b/src/app/courseflow/common/unit-card/unit-card.component.scss index 332ccfd35..6a9d84345 100644 --- a/src/app/courseflow/common/unit-card/unit-card.component.scss +++ b/src/app/courseflow/common/unit-card/unit-card.component.scss @@ -26,6 +26,25 @@ background-color: #f2f2f2; transform: translateY(-2px); } + + &.completed { + background-color: #e8f5e8; + border-color: #4caf50; + + &:hover { + background-color: #d4edda; + } + } + + &.drag-disabled { + cursor: not-allowed; + opacity: 0.8; + + &:hover { + transform: none; + background-color: #e8f5e8; + } + } } .unit-menu-button { @@ -46,3 +65,13 @@ height: 18px; line-height: 18px; } + +.completion-indicator { + position: absolute; + top: 5px; + left: 5px; + color: #4caf50; + font-size: 16px; + width: 16px; + height: 16px; +} diff --git a/src/app/courseflow/common/unit-card/unit-card.component.ts b/src/app/courseflow/common/unit-card/unit-card.component.ts index d2955e9e1..acdd77987 100644 --- a/src/app/courseflow/common/unit-card/unit-card.component.ts +++ b/src/app/courseflow/common/unit-card/unit-card.component.ts @@ -18,8 +18,29 @@ export class UnitCardComponent { @Input() dragData!: any; @Input() showMenu = false; @Output() removeUnit = new EventEmitter(); + @Output() toggleCompletion = new EventEmitter(); + @Output() showSkillsSummary = new EventEmitter(); + + // Track completion status locally if not available on the unit + get isCompleted(): boolean { + // Check if unit has a completion property, otherwise use local storage or default to false + const unitWithCompletion = this.unit as CourseUnit & {isCompleted?: boolean}; + return unitWithCompletion.isCompleted || false; + } onRemoveUnit(): void { + // Don't allow removal of completed units + if (this.isCompleted) { + return; + } this.removeUnit.emit(); } + + onToggleCompletion(): void { + this.toggleCompletion.emit(); + } + + onShowSkillsSummary(): void { + this.showSkillsSummary.emit(this.unit); + } } diff --git a/src/app/courseflow/services/course-map-state.service.ts b/src/app/courseflow/services/course-map-state.service.ts index 3289ef30e..7582cb1c8 100644 --- a/src/app/courseflow/services/course-map-state.service.ts +++ b/src/app/courseflow/services/course-map-state.service.ts @@ -386,4 +386,16 @@ export class CourseMapStateService { requiredUnits: units, }); } + + toggleUnitCompletion(unit: CourseUnit): void { + // For now, we'll store completion status on the unit object itself + // In a real implementation, this might be stored in a service or backend + const unitWithCompletion = unit as CourseUnit & {isCompleted?: boolean}; + unitWithCompletion.isCompleted = !unitWithCompletion.isCompleted; + + // Trigger a state update to ensure components re-render + this.updateState({ + ...this.currentState, + }); + } } diff --git a/src/app/courseflow/states/coursemap/coursemap.component.html b/src/app/courseflow/states/coursemap/coursemap.component.html index c8c32910e..f22c5ae49 100644 --- a/src/app/courseflow/states/coursemap/coursemap.component.html +++ b/src/app/courseflow/states/coursemap/coursemap.component.html @@ -31,6 +31,7 @@

Study Periods

[yearIndex]="yIndex" [stateService]="stateService" (dropEvent)="handleDrop($event)" + (showSkillsSummary)="onShowSkillsSummary($event)" > diff --git a/src/app/courseflow/states/coursemap/coursemap.component.ts b/src/app/courseflow/states/coursemap/coursemap.component.ts index a21050fbe..27e55ded8 100644 --- a/src/app/courseflow/states/coursemap/coursemap.component.ts +++ b/src/app/courseflow/states/coursemap/coursemap.component.ts @@ -2,11 +2,12 @@ import {Component, OnInit, OnDestroy} 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 {DragDropModule} from '@angular/cdk/drag-drop'; import {Subject, takeUntil} from 'rxjs'; import {CourseMapStateService} from '../../services/course-map-state.service'; import {CourseMapDragDropService} from '../../services/course-map-drag-drop.service'; -import {CourseMapState} from '../../models/course-map.models'; +import {CourseMapState, CourseUnit} from '../../models/course-map.models'; import { UnitService, CourseService, @@ -23,6 +24,7 @@ import {CourseYearEditorComponent} from './directives/course-year-editor/course- import {RequiredUnitsListComponent} from './directives/required-units-list/required-units-list.component'; import {ElectiveUnitsListComponent} from './directives/elective-units-list/elective-units-list.component'; import {UnitSearchComponent} from './directives/unit-search/unit-search.component'; +import {SkillsSummaryDialogComponent} from '../../common/skills-summary-dialog/skills-summary-dialog.component'; @Component({ selector: 'coursemap', @@ -38,6 +40,7 @@ import {UnitSearchComponent} from './directives/unit-search/unit-search.componen RequiredUnitsListComponent, ElectiveUnitsListComponent, UnitSearchComponent, + SkillsSummaryDialogComponent, ], providers: [ UnitService, @@ -71,6 +74,7 @@ export class CoursemapComponent implements OnInit, OnDestroy { private courseMapUnitService: CourseMapUnitService, private authService: AuthenticationService, private alerts: AlertService, + private dialog: MatDialog, ) { this.state = this.stateService.currentState; } @@ -252,4 +256,32 @@ export class CoursemapComponent implements OnInit, OnDestroy { trackByYear(index: number, year: any): number { return year.year; } + + onShowSkillsSummary(unit: CourseUnit): void { + // Collect all units placed in the course map + const allPlacedUnits: CourseUnit[] = []; + + this.state.years.forEach(year => { + Object.keys(year).forEach(key => { + if (key.startsWith('trimester')) { + const trimester = year[key as keyof typeof year] as (CourseUnit | null)[]; + if (trimester) { + trimester.forEach(u => { + if (u) { + allPlacedUnits.push(u); + } + }); + } + } + }); + }); + + this.dialog.open(SkillsSummaryDialogComponent, { + width: '800px', + maxWidth: '90vw', + data: { + units: allPlacedUnits + } + }); + } } diff --git a/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.html b/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.html index bbc646593..1d66c9313 100644 --- a/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.html +++ b/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.html @@ -21,6 +21,7 @@

Year {{ year.year }}

[stateService]="stateService" (dropEvent)="onTrimesterDrop($event)" (deleteTrimester)="deleteTrimester(tIndex)" + (showSkillsSummary)="onShowSkillsSummary($event)" > diff --git a/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.ts b/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.ts index e38f889b5..deadd9d00 100644 --- a/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.ts +++ b/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.ts @@ -2,7 +2,7 @@ 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 {CourseYear, TRIMESTER_KEYS} from '../../../../models/course-map.models'; +import {CourseYear, CourseUnit, TRIMESTER_KEYS} from '../../../../models/course-map.models'; import {CourseMapStateService} from '../../../../services/course-map-state.service'; import {TrimesterEditorComponent} from '../trimester-editor/trimester-editor.component'; @@ -19,6 +19,7 @@ export class CourseYearEditorComponent { @Input() stateService!: CourseMapStateService; // eslint-disable-next-line @typescript-eslint/no-explicit-any @Output() dropEvent = new EventEmitter(); + @Output() showSkillsSummary = new EventEmitter(); readonly trimesterKeys = TRIMESTER_KEYS; @@ -46,4 +47,8 @@ export class CourseYearEditorComponent { onTrimesterDrop(event: any): void { this.dropEvent.emit(event); } + + onShowSkillsSummary(unit: CourseUnit): void { + this.showSkillsSummary.emit(unit); + } } 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 bf3758676..2ceb0f789 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 @@ -12,6 +12,8 @@ [slotIndex]="slotIndex" (dropEvent)="onSlotDrop($event)" (removeUnit)="onRemoveUnit(slotIndex)" + (toggleCompletion)="onToggleCompletion($event)" + (showSkillsSummary)="onShowSkillsSummary($event)" >
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 2e36aac81..3f239b2d5 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 @@ -21,6 +21,7 @@ export class TrimesterEditorComponent { @Input() stateService!: CourseMapStateService; @Output() dropEvent = new EventEmitter(); @Output() deleteTrimester = new EventEmitter(); + @Output() showSkillsSummary = new EventEmitter(); readonly slotIndices = [0, 1, 2, 3]; @@ -40,6 +41,14 @@ export class TrimesterEditorComponent { this.stateService.removeUnitFromSlot(this.yearIndex, this.trimesterKey, slotIndex); } + onToggleCompletion(unit: CourseUnit): void { + this.stateService.toggleUnitCompletion(unit); + } + + onShowSkillsSummary(unit: CourseUnit): void { + this.showSkillsSummary.emit(unit); + } + trackBySlotIndex(index: number): number { return index; } diff --git a/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.html b/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.html index 64de599fc..8b13e158b 100644 --- a/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.html +++ b/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.html @@ -12,6 +12,8 @@ [dragData]="dragData" [showMenu]="true" (removeUnit)="onRemoveUnit()" + (toggleCompletion)="onToggleCompletion()" + (showSkillsSummary)="onShowSkillsSummary($event)" >
diff --git a/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.ts b/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.ts index a5ce8dc29..f096e46ac 100644 --- a/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.ts +++ b/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.ts @@ -29,6 +29,8 @@ export class UnitSlotComponent { @Input() slotIndex!: number; @Output() dropEvent = new EventEmitter(); @Output() removeUnit = new EventEmitter(); + @Output() toggleCompletion = new EventEmitter(); + @Output() showSkillsSummary = new EventEmitter(); get dropListId(): string { return `${this.trimesterKey}-${this.yearIndex}-slot-${this.slotIndex}`; @@ -59,4 +61,14 @@ export class UnitSlotComponent { onRemoveUnit(): void { this.removeUnit.emit(); } + + onToggleCompletion(): void { + if (this.unit) { + this.toggleCompletion.emit(this.unit); + } + } + + onShowSkillsSummary(unit: CourseUnit): void { + this.showSkillsSummary.emit(unit); + } } From 6b99840f58a05fca7cd3d9df0d66f2f8055c5767 Mon Sep 17 00:00:00 2001 From: giangnht19 Date: Mon, 25 Aug 2025 18:46:23 +1000 Subject: [PATCH 2/5] fix: fixed unit_definition not showing up --- .../courseflow/models/course-map.models.ts | 8 +- .../services/course-map-state.service.ts | 73 ++++++++++++---- .../states/coursemap/coursemap.component.ts | 87 +++++++++++-------- 3 files changed, 110 insertions(+), 58 deletions(-) diff --git a/src/app/courseflow/models/course-map.models.ts b/src/app/courseflow/models/course-map.models.ts index 61253050c..549acc0fa 100644 --- a/src/app/courseflow/models/course-map.models.ts +++ b/src/app/courseflow/models/course-map.models.ts @@ -1,6 +1,6 @@ import {Unit, UnitDefinition} from 'src/app/api/models/doubtfire-model'; -export type CourseUnit = Unit; +export type CourseUnit = Unit | UnitDefinition; export interface SlotContext { yearIndex: number; @@ -17,9 +17,9 @@ export interface CourseYear { export interface CourseMapState { years: CourseYear[]; - requiredUnits: Unit[]; - allRequiredUnits: Unit[]; - electiveUnits: Unit[]; + requiredUnits: UnitDefinition[]; // Course templates for planning + allRequiredUnits: UnitDefinition[]; // All available course templates + electiveUnits: Unit[]; // Actual course instances for electives maxElectiveUnits: number; } diff --git a/src/app/courseflow/services/course-map-state.service.ts b/src/app/courseflow/services/course-map-state.service.ts index 7582cb1c8..29032c76b 100644 --- a/src/app/courseflow/services/course-map-state.service.ts +++ b/src/app/courseflow/services/course-map-state.service.ts @@ -1,7 +1,7 @@ import {Injectable} from '@angular/core'; import {BehaviorSubject, Observable} from 'rxjs'; import {CourseYear, CourseMapState, CourseUnit, TRIMESTER_KEYS} from '../models/course-map.models'; -import {Unit, UnitDefinition} from 'src/app/api/models/doubtfire-model'; +import {Unit, UnitDefinition, CourseMapUnit} from 'src/app/api/models/doubtfire-model'; @Injectable({ providedIn: 'root', // provide 1 instance throughout the entire application -> singleton @@ -220,7 +220,7 @@ export class CourseMapStateService { updatedYears[yearIndex] = updatedYear; // Return the required unit (if yes) to the required unit list - let updatedRequiredUnits = [...currentState.requiredUnits]; + const updatedRequiredUnits = [...currentState.requiredUnits]; if (this.isRequiredUnit(unitToRemove)) { if (!updatedRequiredUnits.some((reqUnit) => reqUnit.id === unitToRemove.id)) { updatedRequiredUnits.push(unitToRemove as Unit); @@ -265,9 +265,10 @@ export class CourseMapStateService { private isRequiredUnit(unit: CourseUnit): boolean { /** - * Check if a unit is a required unit + * Check if a unit is a required unit by comparing codes (not IDs) + * since Unit instances and UnitDefinitions may have different IDs for the same course */ - return this.currentState.allRequiredUnits.some((reqUnit) => reqUnit.id === unit.id); + return this.currentState.allRequiredUnits.some((reqUnit) => reqUnit.code === unit.code); } private isElectiveInSlots(unit: Unit): boolean { @@ -319,23 +320,51 @@ export class CourseMapStateService { // Initialize state from coursemap data // eslint-disable-next-line @typescript-eslint/no-explicit-any - initializeFromCourseMapUnits(courseMapUnits: any[], allRequiredUnits: Unit[]): void { + initializeFromCourseMapUnits( + courseMapUnits: CourseMapUnit[], + allRequiredUnitDefinitions: UnitDefinition[], + unitDefinitions?: UnitDefinition[], + ): void { const years: CourseYear[] = []; const placedUnitIds: number[] = []; + const missingUnitIds: number[] = []; - // Create a map of unitId to Unit for quick lookup + // Create a map of unitId to Unit for quick lookup (for existing required units) const unitMap = new Map(); - allRequiredUnits.forEach((unit) => { - unitMap.set(unit.id, unit); + // Note: We don't populate unitMap since we're not using actual Unit instances for required units anymore + + // Create a map of unitDefinitionId to UnitDefinition for quick lookup + const unitDefinitionMap = new Map(); + if (unitDefinitions) { + unitDefinitions.forEach((unitDef) => { + if (unitDef.id !== undefined) { + unitDefinitionMap.set(unitDef.id, unitDef); + } + }); + } + + // Also add the required unit definitions to the map + allRequiredUnitDefinitions.forEach((unitDef) => { + if (unitDef.id !== undefined) { + unitDefinitionMap.set(unitDef.id, unitDef); + } }); // Process course map units into years/trimesters courseMapUnits.forEach((courseMapUnit) => { - // Find the corresponding unit - const unit = unitMap.get(courseMapUnit.unitId); + let unit: CourseUnit | null = null; + + // First try to find an existing unit with matching ID + unit = unitMap.get(courseMapUnit.unitId) || null; + + // If not found, try to find a unit definition with matching ID + if (!unit && unitDefinitionMap.has(courseMapUnit.unitId)) { + unit = unitDefinitionMap.get(courseMapUnit.unitId)!; + } if (!unit) { - console.warn(`Unit not found for unitId: ${courseMapUnit.unitId}`); + // Track missing units for reporting but don't spam console + missingUnitIds.push(courseMapUnit.unitId); return; // Skip this unit if we can't find its definition } @@ -363,27 +392,37 @@ export class CourseMapStateService { } }); + // Report missing units once if any were found + if (missingUnitIds.length > 0) { + console.warn( + `Course map initialization: ${missingUnitIds.length} units were referenced but not found in the available units list. Unit IDs: ${missingUnitIds.join(', ')}`, + ); + console.info( + 'This may indicate that some units are no longer available or were moved. The course map will continue to load with the available units.', + ); + } + // Sort years by year value years.sort((a, b) => a.year - b.year); - // Filter required units to only include those not already placed - const unplacedRequiredUnits = allRequiredUnits.filter( - (unit) => !placedUnitIds.includes(unit.id), + // Filter required unit definitions to only include those not already placed + const unplacedRequiredUnits = allRequiredUnitDefinitions.filter( + (unitDef) => !placedUnitIds.includes(unitDef.id!), ); this.updateState({ ...this.currentState, years: years.length > 0 ? years : this.initialState.years, requiredUnits: unplacedRequiredUnits, - allRequiredUnits: allRequiredUnits, + allRequiredUnits: allRequiredUnitDefinitions, electiveUnits: [], // Start with no elective units }); } - updateRequiredUnits(units: Unit[]): void { + updateRequiredUnits(unitDefinitions: UnitDefinition[]): void { this.updateState({ ...this.currentState, - requiredUnits: units, + requiredUnits: unitDefinitions, }); } diff --git a/src/app/courseflow/states/coursemap/coursemap.component.ts b/src/app/courseflow/states/coursemap/coursemap.component.ts index 27e55ded8..9c62d0c6f 100644 --- a/src/app/courseflow/states/coursemap/coursemap.component.ts +++ b/src/app/courseflow/states/coursemap/coursemap.component.ts @@ -40,7 +40,6 @@ import {SkillsSummaryDialogComponent} from '../../common/skills-summary-dialog/s RequiredUnitsListComponent, ElectiveUnitsListComponent, UnitSearchComponent, - SkillsSummaryDialogComponent, ], providers: [ UnitService, @@ -150,7 +149,7 @@ export class CoursemapComponent implements OnInit, OnDestroy { private loadData(): void { this.loadUnits(); - // this.loadUnitDefinitions(); + this.loadUnitDefinitions(); } private loadCourseMap(): void { @@ -177,20 +176,20 @@ export class CoursemapComponent implements OnInit, OnDestroy { }); } - // private loadUnitDefinitions(): void { - // this.unitDefinitionService.getDefinitions().subscribe({ - // next: (data: UnitDefinition[]) => { - // this.unitDefinitions = data; - // this.errorMessage = null; - // this.initializeMap(); - // console.log('Unit Definitions:', this.unitDefinitions); - // }, - // error: (err) => { - // this.errorMessage = 'Error fetching unit definitions'; - // console.error('Error fetching unit definitions:', err); - // }, - // }); - // } + private loadUnitDefinitions(): void { + this.unitDefinitionService.getDefinitions().subscribe({ + next: (data: UnitDefinition[]) => { + this.unitDefinitions = data; + this.errorMessage = null; + this.initializeMap(); + console.log('Unit Definitions:', this.unitDefinitions); + }, + error: (err) => { + this.errorMessage = 'Error fetching unit definitions'; + console.error('Error fetching unit definitions:', err); + }, + }); + } private loadCourseMapUnits(): void { if (!this.currentCourseMapId) { @@ -202,12 +201,17 @@ export class CoursemapComponent implements OnInit, OnDestroy { next: (data: CourseMapUnit[]) => { this.courseMapUnits = data; - const requiredUnitIds = new Set(this.courseMapUnits.map((cmu) => cmu.unitId)); - this.requiredUnits = this.units.filter((u) => requiredUnitIds.has(u.id)); + // Use unit definitions as required units (course templates for the map) + // and keep actual units separate for electives + this.requiredUnits = []; + if (this.unitDefinitions) { + // Use unit definitions as the basis for required units + this.requiredUnits = this.unitDefinitions.map((unitDef) => unitDef as Unit); + } this.initializeMap(); console.log('Course Map Units:', this.courseMapUnits); - console.log('All Required Units:', this.requiredUnits); + console.log('All Required Units (from unit definitions):', this.requiredUnits); }, error: (err) => { this.errorMessage = 'Error fetching course map units'; @@ -217,11 +221,15 @@ export class CoursemapComponent implements OnInit, OnDestroy { } private initializeMap(): void { - if (this.courseMapUnits && this.requiredUnits) { - this.stateService.initializeFromCourseMapUnits(this.courseMapUnits, this.requiredUnits); + if (this.courseMapUnits && this.unitDefinitions) { + this.stateService.initializeFromCourseMapUnits( + this.courseMapUnits, + this.unitDefinitions, // Pass unit definitions as required units + this.unitDefinitions || undefined, + ); console.log('Course map initialized with:', { courseMapUnits: this.courseMapUnits, - requiredUnits: this.requiredUnits, + unitDefinitions: this.unitDefinitions, }); } } @@ -236,8 +244,9 @@ export class CoursemapComponent implements OnInit, OnDestroy { } getAvailableUnits(): Unit[] { - const allRequiredIds = new Set(this.state.allRequiredUnits.map((u) => u.id)); - return this.units.filter((unit) => !allRequiredIds.has(unit.id)); + // Return actual Unit instances (not UnitDefinitions) for elective selection + // These are running courses that can be added as electives + return this.units || []; } getRemainingElectiveSlots(): number { @@ -253,20 +262,19 @@ 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; } - onShowSkillsSummary(unit: CourseUnit): void { - // Collect all units placed in the course map + onShowSkillsSummary(_unit: CourseUnit): void { + // Extract all units placed in the course map const allPlacedUnits: CourseUnit[] = []; - - this.state.years.forEach(year => { - Object.keys(year).forEach(key => { - if (key.startsWith('trimester')) { - const trimester = year[key as keyof typeof year] as (CourseUnit | null)[]; + this.state.years.forEach((year) => { + Object.keys(year).forEach((key) => { + if (key.startsWith('trimester') && year[key]) { + const trimester = year[key] as (CourseUnit | null)[]; if (trimester) { - trimester.forEach(u => { + trimester.forEach((u) => { if (u) { allPlacedUnits.push(u); } @@ -276,12 +284,17 @@ export class CoursemapComponent implements OnInit, OnDestroy { }); }); - this.dialog.open(SkillsSummaryDialogComponent, { + console.log('Opening skills summary with units:', allPlacedUnits); + const dialogRef = this.dialog.open(SkillsSummaryDialogComponent, { width: '800px', - maxWidth: '90vw', + height: '600px', data: { - units: allPlacedUnits - } + units: allPlacedUnits, + }, + }); + + dialogRef.afterClosed().subscribe(() => { + console.log('Skills summary dialog closed'); }); } } From c40fa363f88bb40480cc9aad7a73b142d68d08b9 Mon Sep 17 00:00:00 2001 From: giangnht19 Date: Wed, 3 Sep 2025 15:40:56 +1000 Subject: [PATCH 3/5] feat: populate the overlay component for the detailed unit with content of the unit --- src/app/api/models/doubtfire-model.ts | 6 + src/app/api/models/requirement-set.ts | 9 +- src/app/api/models/requirement.ts | 11 + .../api/services/requirement-set.service.ts | 67 ++--- src/app/api/services/requirement.service.ts | 38 +++ .../common/unit-card/unit-card.component.html | 4 + .../common/unit-card/unit-card.component.ts | 5 + .../unit-details-overlay.component.html | 208 ++++++++++++++++ .../unit-details-overlay.component.scss | 187 ++++++++++++++ .../unit-details-overlay.component.ts | 229 ++++++++++++++++++ .../courseflow/models/course-map.models.ts | 4 +- .../services/course-map-state.service.ts | 31 ++- .../states/coursemap/coursemap.component.html | 1 + .../states/coursemap/coursemap.component.ts | 49 +++- .../course-year-editor.component.html | 1 + .../course-year-editor.component.ts | 5 + .../trimester-editor.component.html | 1 + .../trimester-editor.component.ts | 5 + .../unit-slot/unit-slot.component.html | 1 + .../unit-slot/unit-slot.component.ts | 5 + 20 files changed, 793 insertions(+), 74 deletions(-) create mode 100644 src/app/api/models/requirement.ts create mode 100644 src/app/api/services/requirement.service.ts create mode 100644 src/app/courseflow/common/unit-details-overlay/unit-details-overlay.component.html create mode 100644 src/app/courseflow/common/unit-details-overlay/unit-details-overlay.component.scss create mode 100644 src/app/courseflow/common/unit-details-overlay/unit-details-overlay.component.ts diff --git a/src/app/api/models/doubtfire-model.ts b/src/app/api/models/doubtfire-model.ts index 573670b3d..53789c324 100644 --- a/src/app/api/models/doubtfire-model.ts +++ b/src/app/api/models/doubtfire-model.ts @@ -62,3 +62,9 @@ export * from '../services/teaching-period-break.service'; export * from '../services/learning-outcome.service'; export * from '../services/group-set.service'; export * from '../services/task-similarity.service'; + +// Courseflow models and services +export * from './requirement'; +export * from './requirement-set'; +export * from '../services/requirement.service'; +export * from '../services/requirement-set.service'; diff --git a/src/app/api/models/requirement-set.ts b/src/app/api/models/requirement-set.ts index a83e1529f..37abf557e 100644 --- a/src/app/api/models/requirement-set.ts +++ b/src/app/api/models/requirement-set.ts @@ -1,8 +1,13 @@ +import {Unit} from './doubtfire-model'; +import {Requirement} from './requirement'; + export interface RequirementSet { - id?: string; + id?: number; requirementSetGroupId: number; description: string; - unitId: number; + unitId?: number; requirementId: number; + unit?: Unit; // Optional populated unit data + requirement?: Requirement; // Optional populated requirement data } diff --git a/src/app/api/models/requirement.ts b/src/app/api/models/requirement.ts new file mode 100644 index 000000000..e66aa99f7 --- /dev/null +++ b/src/app/api/models/requirement.ts @@ -0,0 +1,11 @@ +export interface Requirement { + id?: number; + courseId: number; + unitId?: number; + type: 'course' | 'unit'; + category: string; + description: string; + minimum?: number; + maximum?: number; + requirementSetGroupId: number; +} diff --git a/src/app/api/services/requirement-set.service.ts b/src/app/api/services/requirement-set.service.ts index 0b1673440..86f925d56 100644 --- a/src/app/api/services/requirement-set.service.ts +++ b/src/app/api/services/requirement-set.service.ts @@ -1,58 +1,37 @@ import {Observable} from 'rxjs'; -import {HttpClient, HttpParams} from '@angular/common/http'; +import {HttpClient} from '@angular/common/http'; import {Injectable} from '@angular/core'; import API_URL from 'src/app/config/constants/apiURL'; +import {RequirementSet} from '../models/requirement-set'; -@Injectable() -export class RequirementSet { +@Injectable({ + providedIn: 'root', +}) +export class RequirementSetService { constructor(private http: HttpClient) {} private baseUrl: string = `${API_URL}/requirementset`; - // Get requirement-sets - getRequirementSets(): Observable { - const url = `${this.baseUrl}`; - return this.http.get(url); - } - - getRequirementSetById(): Observable { - const url = `${this.baseUrl}/:id:`; - return this.http.get(url); - } - - addNewRequirementSet( - requirementSetGroupId: number, - name: string, - description: string, - unitId: string, - requirementId: number): Observable { - const params = new HttpParams(); - - params.set('requirementSetId', requirementSetGroupId.toString()); - params.set('name', name); - params.set('description', description); - params.set('unitId', unitId); - params.set('requirementId', requirementId.toString()); - const url = `${this.baseUrl}`; - return this.http.post(url, {params}); + /** + * Get all requirement sets + */ + getAllRequirementSets(): Observable { + return this.http.get(this.baseUrl); } - updateRequirementSet( - requirementSetGroupId: number, - name: string, - description: string, - code: string,): Observable { - const params = new HttpParams(); - - params.set('requirementSetId', requirementSetGroupId.toString()); - params.set('description', description); - const url = `${this.baseUrl}`; - return this.http.put(url, {params}); + /** + * Get requirement sets by group ID + */ + getRequirementSetsByGroupId(groupId: number): Observable { + const url = `${this.baseUrl}/requirementSetGroupId/${groupId}`; + return this.http.get(url); } - deleteRequirementSet(requirementSetGroupId: number): Observable { - const url = `${this.baseUrl}/requirementSetId/${requirementSetGroupId}`; - return this.http.delete(url); + /** + * Get a specific requirement set by ID + */ + getRequirementSetById(id: number): Observable { + const url = `${this.baseUrl}/${id}`; + return this.http.get(url); } - } diff --git a/src/app/api/services/requirement.service.ts b/src/app/api/services/requirement.service.ts new file mode 100644 index 000000000..c477f0a25 --- /dev/null +++ b/src/app/api/services/requirement.service.ts @@ -0,0 +1,38 @@ +import {Observable} from 'rxjs'; +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import API_URL from 'src/app/config/constants/apiURL'; +import {Requirement} from '../models/requirement'; + +@Injectable({ + providedIn: 'root', +}) +export class RequirementService { + constructor(private http: HttpClient) {} + + private baseUrl: string = `${API_URL}/requirement`; + + /** + * Get all requirements for a course + */ + getRequirementsByCourseId(courseId: number): Observable { + const url = `${this.baseUrl}/courseId/${courseId}`; + return this.http.get(url); + } + + /** + * Get requirements for a specific unit + */ + getRequirementsByUnitId(unitId: number): Observable { + const url = `${this.baseUrl}/unitId/${unitId}`; + return this.http.get(url); + } + + /** + * Get all requirements + */ + getAllRequirements(): Observable { + const url = `${this.baseUrl}`; + return this.http.get(url); + } +} 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 e77ce8073..965ecb326 100644 --- a/src/app/courseflow/common/unit-card/unit-card.component.html +++ b/src/app/courseflow/common/unit-card/unit-card.component.html @@ -18,6 +18,10 @@ {{ isCompleted ? 'radio_button_checked' : 'radio_button_unchecked' }} {{ isCompleted ? 'Mark as Incomplete' : 'Mark as Complete' }} + + + +
+ + + + + description + Description + + + +

{{ unitDescription }}

+
+
+ + + @if (learningOutcomes.length > 0) { + + + + psychology + Learning Outcomes ({{ learningOutcomes.length }}) + + + + + @for (outcome of learningOutcomes; track outcome.id) { + +
+ {{ outcome.abbreviation }} - {{ outcome.name }} +
+
+ {{ outcome.description }} +
+
+ @if (!$last) { + + } + } +
+
+
+ } + + + @if (taskDefinitions.length > 0) { + + + + assignment + Assessment Tasks ({{ taskDefinitions.length }}) + + + + + @for (task of taskDefinitions; track task.id) { + +
+ {{ task.abbreviation }} - {{ task.name }} +
+
+ Target Grade: {{ task.targetGrade }} + @if (task.dueDate) { + Due: {{ task.dueDate | date }} + } +
+ @if (getTaskAlignments(task.id).length > 0) { +
+ + @for (alignment of getTaskAlignments(task.id); track alignment.id) { + + {{ alignment.learningOutcome.abbreviation }} ({{ alignment.rating }}/5) + + } + +
+ } +
+ @if (!$last) { + + } + } +
+
+
+ } + + + + + + rule + Unit Requirements + + + + @if (isUnitDefinition) { +

Requirements information is available for active unit instances only.

+ } @else { +
+ @if (requirementsLoading) { +
+ hourglass_empty + Loading requirements... +
+ } @else if (requirementsError) { +
+ error + {{ requirementsError }} +
+ } @else { + +

Prerequisites

+ @if (hasPrerequisites) { + + @for (prereq of prerequisites; track prereq) { + {{ prereq }} + } + + } @else { +

No prerequisites required

+ } + + + @if (unitRequirements.length > 0) { +

Unit Requirements

+
+ @for (requirement of unitRequirements; track requirement.id) { +
+ {{ requirement.category | titlecase }}: + {{ requirement.description }} + @if (requirement.minimum || requirement.maximum) { + + ({{ requirement.minimum }}{{ requirement.maximum && requirement.maximum !== requirement.minimum ? '-' + requirement.maximum : '' }}) + + } +
+ } +
+ } + + + @if (courseRequirements.length > 0) { +

Course Requirements

+
+ @for (requirement of courseRequirements; track requirement.id) { +
+ {{ requirement.category | titlecase }}: + {{ requirement.description }} + @if (requirement.minimum || requirement.maximum) { + + ({{ requirement.minimum }}{{ requirement.maximum && requirement.maximum !== requirement.minimum ? '-' + requirement.maximum : '' }}) + + } +
+ } +
+ } @else { +

Course Rules

+

+ + Course-level requirements and rules are managed through the courseflow system. + This includes credit point requirements, level restrictions, and capstone options. + +

+ } + + +
+

Unit Information

+

Unit Code: {{ unitCode }}

+

Credit Points: {{ creditPoints || 'Not specified' }}

+ @if (unitAsUnit?.teachingPeriod) { +

+ Teaching Period: {{ unitAsUnit.teachingPeriod.period }} + {{ unitAsUnit.teachingPeriod.year }} +

+ } + @if (unitAsUnit?.active !== undefined) { +

Status: {{ unitAsUnit.active ? 'Active' : 'Inactive' }}

+ } +
+ } +
+ } +
+
+
+ +
+ +
+ diff --git a/src/app/courseflow/common/unit-details-overlay/unit-details-overlay.component.scss b/src/app/courseflow/common/unit-details-overlay/unit-details-overlay.component.scss new file mode 100644 index 000000000..468445369 --- /dev/null +++ b/src/app/courseflow/common/unit-details-overlay/unit-details-overlay.component.scss @@ -0,0 +1,187 @@ +.unit-details-overlay { + min-width: 600px; + max-width: 800px; + max-height: 80vh; +} + +.dialog-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 0; +} + +.unit-header { + display: flex; + align-items: center; + gap: 16px; +} + +.unit-icon { + font-size: 32px; + width: 32px; + height: 32px; + color: #1976d2; +} + +.unit-title h2 { + margin: 0; + font-size: 24px; + font-weight: 500; +} + +.credit-points { + color: #666; + font-size: 14px; + font-weight: normal; +} + +.dialog-content { + max-height: 60vh; + overflow-y: auto; + padding: 0 24px; +} + +.info-card { + margin-bottom: 16px; +} + +.info-card mat-card-header { + padding-bottom: 8px; +} + +.info-card mat-card-title { + display: flex; + align-items: center; + gap: 8px; + font-size: 18px; +} + +.info-card mat-icon { + color: #1976d2; +} + +.outcome-description { + color: #666; + font-size: 14px; + margin-top: 4px; +} + +.task-details { + display: flex; + gap: 16px; + color: #666; + font-size: 14px; + margin-top: 4px; +} + +.task-alignments { + margin-top: 8px; +} + +.task-alignments mat-chip-listbox { + gap: 4px; +} + +mat-chip-option { + font-size: 12px; + min-height: 24px; + padding: 0 8px; +} + +.dialog-actions { + padding: 16px 24px; + display: flex; + justify-content: flex-end; +} + +mat-list-item { + min-height: auto !important; + height: auto !important; + padding: 12px 0; +} + +mat-divider { + margin: 8px 0; +} + +.requirements-section { + h4 { + margin: 16px 0 8px 0; + color: #333; + font-size: 16px; + font-weight: 500; + } + + .course-rules-title { + margin-top: 24px; + } + + .unit-info { + margin-top: 24px; + padding: 16px; + background-color: #f5f5f5; + border-radius: 8px; + + h4 { + margin-top: 0; + } + + p { + margin: 8px 0; + + strong { + color: #333; + } + } + } + + mat-chip-listbox { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + mat-chip-option { + background-color: #e3f2fd; + color: #1976d2; + border: 1px solid #bbdefb; + } + + .loading-indicator, .error-message { + display: flex; + align-items: center; + gap: 8px; + padding: 16px; + margin: 16px 0; + border-radius: 8px; + } + + .loading-indicator { + background-color: #f5f5f5; + color: #666; + } + + .error-message { + background-color: #ffebee; + color: #c62828; + } + + .requirements-list { + margin: 12px 0; + } + + .requirement-item { + padding: 8px; + margin: 4px 0; + background-color: #fafafa; + border-left: 3px solid #1976d2; + border-radius: 4px; + + .requirement-count { + font-size: 0.9em; + color: #666; + margin-left: 8px; + } + } +} diff --git a/src/app/courseflow/common/unit-details-overlay/unit-details-overlay.component.ts b/src/app/courseflow/common/unit-details-overlay/unit-details-overlay.component.ts new file mode 100644 index 000000000..26c383495 --- /dev/null +++ b/src/app/courseflow/common/unit-details-overlay/unit-details-overlay.component.ts @@ -0,0 +1,229 @@ +import {Component, Inject, OnInit} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {MatDialogModule, MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatCardModule} from '@angular/material/card'; +import {MatListModule} from '@angular/material/list'; +import {MatChipsModule} from '@angular/material/chips'; +import {MatDividerModule} from '@angular/material/divider'; +import {CourseUnit} from '../../models/course-map.models'; +import { + Unit, + UnitDefinition, + Requirement, + RequirementSet, +} from 'src/app/api/models/doubtfire-model'; +import {LearningOutcome} from 'src/app/api/models/learning-outcome'; +import {TaskOutcomeAlignment} from 'src/app/api/models/task-outcome-alignment'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; +import {RequirementService} from 'src/app/api/services/requirement.service'; +import {RequirementSetService} from 'src/app/api/services/requirement-set.service'; +import {forkJoin, Observable, of} from 'rxjs'; +import {map, catchError} from 'rxjs/operators'; + +@Component({ + selector: 'f-unit-details-overlay', + templateUrl: './unit-details-overlay.component.html', + styleUrls: ['./unit-details-overlay.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatButtonModule, + MatIconModule, + MatCardModule, + MatListModule, + MatChipsModule, + MatDividerModule, + ], +}) +export class UnitDetailsOverlayComponent implements OnInit { + public unitRequirements: Requirement[] = []; + public courseRequirements: Requirement[] = []; + public prerequisiteUnits: string[] = []; + public requirementsLoading = false; + public requirementsError: string | null = null; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public unit: CourseUnit, + private requirementService: RequirementService, + private requirementSetService: RequirementSetService, + ) {} + + ngOnInit(): void { + this.loadRequirements(); + } + + private loadRequirements(): void { + if (!this.isUnit || !this.unitAsUnit?.id) { + return; + } + + this.requirementsLoading = true; + this.requirementsError = null; + + // Get both unit-specific requirements (prerequisites) and course requirements + const unitRequirements$ = this.requirementService.getRequirementsByUnitId(this.unitAsUnit.id); + const courseRequirements$ = this.getCourseRequirements(); + + forkJoin({ + unitRequirements: unitRequirements$, + courseRequirements: courseRequirements$, + }) + .pipe( + catchError((error) => { + console.error('Error loading requirements:', error); + this.requirementsError = 'Failed to load requirements information'; + return of({unitRequirements: [], courseRequirements: []}); + }), + ) + .subscribe({ + next: (data) => { + this.unitRequirements = data.unitRequirements; + this.courseRequirements = data.courseRequirements; + this.loadPrerequisiteUnits(); + this.requirementsLoading = false; + }, + error: (error) => { + console.error('Error in requirements subscription:', error); + this.requirementsError = 'Failed to load requirements information'; + this.requirementsLoading = false; + }, + }); + } + + private getCourseRequirements(): Observable { + // Try to get course ID from the unit or use a default course ID + // In your test data, you created course S326 with ID that we need to determine + const courseId = 1; // You might need to adjust this based on your data + return this.requirementService.getRequirementsByCourseId(courseId).pipe( + catchError((error) => { + console.warn('Could not load course requirements:', error); + return of([]); + }), + ); + } + + private loadPrerequisiteUnits(): void { + // Find prerequisite requirements for this unit + const prerequisiteRequirements = this.unitRequirements.filter( + (req) => req.category === 'prerequisite', + ); + + if (prerequisiteRequirements.length === 0) { + return; + } + + // For each prerequisite requirement, get the requirement sets to find the actual units + const prerequisitePromises = prerequisiteRequirements.map((req) => + this.requirementSetService + .getRequirementSetsByGroupId(req.requirementSetGroupId) + .pipe( + map((sets) => sets.map((set) => set.unit?.code || set.description)), + catchError(() => of([])), + ), + ); + + forkJoin(prerequisitePromises).subscribe({ + next: (prerequisiteLists) => { + this.prerequisiteUnits = prerequisiteLists.flat().filter((code) => code); + }, + error: (error) => { + console.error('Error loading prerequisite units:', error); + }, + }); + } + + get isUnit(): boolean { + return this.unit && 'teachingPeriod' in this.unit; + } + + get isUnitDefinition(): boolean { + return this.unit && !('teachingPeriod' in this.unit); + } + + get unitAsUnit(): Unit | null { + return this.isUnit ? (this.unit as Unit) : null; + } + + get unitAsUnitDefinition(): UnitDefinition | null { + return this.isUnitDefinition ? (this.unit as UnitDefinition) : null; + } + + get unitCode(): string { + return this.unit.code; + } + + get unitName(): string { + return this.unit.name; + } + + get unitDescription(): string { + return this.unit.description || 'No description available'; + } + + get creditPoints(): number | null { + // For Unit instances, credit points might be available + if (this.isUnit && 'creditPoints' in this.unit) { + return (this.unit as Unit & {creditPoints?: number}).creditPoints || null; + } + // For UnitDefinition, we might need to extract from description or handle differently + return null; + } + + get learningOutcomes(): readonly LearningOutcome[] { + if (this.isUnit && this.unitAsUnit?.learningOutcomesCache) { + return this.unitAsUnit.learningOutcomesCache.currentValues; + } + return []; + } + + get taskOutcomeAlignments(): readonly TaskOutcomeAlignment[] { + if (this.isUnit && this.unitAsUnit?.taskOutcomeAlignments) { + return this.unitAsUnit.taskOutcomeAlignments; + } + return []; + } + + get taskDefinitions(): readonly TaskDefinition[] { + if (this.isUnit && this.unitAsUnit?.taskDefinitionCache) { + return this.unitAsUnit.taskDefinitionCache.currentValues; + } + return []; + } + + get prerequisites(): string[] { + // Return the dynamically loaded prerequisites, fall back to hardcoded ones for demo + if (this.prerequisiteUnits.length > 0) { + return this.prerequisiteUnits; + } + + // Fallback to hardcoded prerequisites based on your test data + const unitCode = this.unitCode; + if (unitCode === 'SIT328') return ['MIS201']; + if (unitCode === 'SIT374') return ['SIT223']; + if (unitCode === 'SIT344') return ['SIT223']; + if (unitCode === 'SIT306') return ['SIT374']; + if (unitCode === 'SIT378') return ['SIT374']; + if (unitCode === 'SIT232') return ['SIT102']; + if (unitCode === 'SIT323') return ['SIT103', 'SIT232']; + + return []; + } + + get hasPrerequisites(): boolean { + return this.prerequisites.length > 0; + } + + getTaskAlignments(taskDefinitionId: number): TaskOutcomeAlignment[] { + return this.taskOutcomeAlignments.filter( + (alignment) => alignment.taskDefinition.id === taskDefinitionId, + ); + } + + onClose(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/courseflow/models/course-map.models.ts b/src/app/courseflow/models/course-map.models.ts index 549acc0fa..64e5baa6b 100644 --- a/src/app/courseflow/models/course-map.models.ts +++ b/src/app/courseflow/models/course-map.models.ts @@ -17,8 +17,8 @@ export interface CourseYear { export interface CourseMapState { years: CourseYear[]; - requiredUnits: UnitDefinition[]; // Course templates for planning - allRequiredUnits: UnitDefinition[]; // All available course templates + requiredUnits: Unit[]; // Actual Unit instances for course requirements + allRequiredUnits: Unit[]; // All available Unit instances electiveUnits: Unit[]; // Actual course instances for electives maxElectiveUnits: number; } diff --git a/src/app/courseflow/services/course-map-state.service.ts b/src/app/courseflow/services/course-map-state.service.ts index 29032c76b..19a372cc6 100644 --- a/src/app/courseflow/services/course-map-state.service.ts +++ b/src/app/courseflow/services/course-map-state.service.ts @@ -322,18 +322,22 @@ export class CourseMapStateService { // eslint-disable-next-line @typescript-eslint/no-explicit-any initializeFromCourseMapUnits( courseMapUnits: CourseMapUnit[], - allRequiredUnitDefinitions: UnitDefinition[], + allRequiredUnits: Unit[], // Changed from UnitDefinition[] to Unit[] unitDefinitions?: UnitDefinition[], ): void { const years: CourseYear[] = []; const placedUnitIds: number[] = []; const missingUnitIds: number[] = []; - // Create a map of unitId to Unit for quick lookup (for existing required units) + // Create a map of unit ID to actual Unit instance for quick lookup const unitMap = new Map(); - // Note: We don't populate unitMap since we're not using actual Unit instances for required units anymore + allRequiredUnits.forEach((unit) => { + if (unit.id !== undefined) { + unitMap.set(unit.id, unit); + } + }); - // Create a map of unitDefinitionId to UnitDefinition for quick lookup + // Create a map of unitDefinitionId to UnitDefinition for quick lookup (fallback) const unitDefinitionMap = new Map(); if (unitDefinitions) { unitDefinitions.forEach((unitDef) => { @@ -343,13 +347,6 @@ export class CourseMapStateService { }); } - // Also add the required unit definitions to the map - allRequiredUnitDefinitions.forEach((unitDef) => { - if (unitDef.id !== undefined) { - unitDefinitionMap.set(unitDef.id, unitDef); - } - }); - // Process course map units into years/trimesters courseMapUnits.forEach((courseMapUnit) => { let unit: CourseUnit | null = null; @@ -405,24 +402,24 @@ export class CourseMapStateService { // Sort years by year value years.sort((a, b) => a.year - b.year); - // Filter required unit definitions to only include those not already placed - const unplacedRequiredUnits = allRequiredUnitDefinitions.filter( - (unitDef) => !placedUnitIds.includes(unitDef.id!), + // Filter required units to only include those not already placed + const unplacedRequiredUnits = allRequiredUnits.filter( + (unit) => !placedUnitIds.includes(unit.id!), ); this.updateState({ ...this.currentState, years: years.length > 0 ? years : this.initialState.years, requiredUnits: unplacedRequiredUnits, - allRequiredUnits: allRequiredUnitDefinitions, + allRequiredUnits: allRequiredUnits, electiveUnits: [], // Start with no elective units }); } - updateRequiredUnits(unitDefinitions: UnitDefinition[]): void { + updateRequiredUnits(units: Unit[]): void { this.updateState({ ...this.currentState, - requiredUnits: unitDefinitions, + requiredUnits: units, }); } diff --git a/src/app/courseflow/states/coursemap/coursemap.component.html b/src/app/courseflow/states/coursemap/coursemap.component.html index f22c5ae49..65fa11070 100644 --- a/src/app/courseflow/states/coursemap/coursemap.component.html +++ b/src/app/courseflow/states/coursemap/coursemap.component.html @@ -32,6 +32,7 @@

Study Periods

[stateService]="stateService" (dropEvent)="handleDrop($event)" (showSkillsSummary)="onShowSkillsSummary($event)" + (showUnitDetails)="onShowUnitDetails($event)" > diff --git a/src/app/courseflow/states/coursemap/coursemap.component.ts b/src/app/courseflow/states/coursemap/coursemap.component.ts index 9c62d0c6f..6481a887f 100644 --- a/src/app/courseflow/states/coursemap/coursemap.component.ts +++ b/src/app/courseflow/states/coursemap/coursemap.component.ts @@ -25,6 +25,7 @@ import {RequiredUnitsListComponent} from './directives/required-units-list/requi import {ElectiveUnitsListComponent} from './directives/elective-units-list/elective-units-list.component'; import {UnitSearchComponent} from './directives/unit-search/unit-search.component'; import {SkillsSummaryDialogComponent} from '../../common/skills-summary-dialog/skills-summary-dialog.component'; +import {UnitDetailsOverlayComponent} from '../../common/unit-details-overlay/unit-details-overlay.component'; @Component({ selector: 'coursemap', @@ -201,17 +202,34 @@ export class CoursemapComponent implements OnInit, OnDestroy { next: (data: CourseMapUnit[]) => { this.courseMapUnits = data; - // Use unit definitions as required units (course templates for the map) - // and keep actual units separate for electives + // Map courseMapUnits to actual Unit instances by finding units with matching unit_definition_id this.requiredUnits = []; - if (this.unitDefinitions) { - // Use unit definitions as the basis for required units - this.requiredUnits = this.unitDefinitions.map((unitDef) => unitDef as Unit); + if (this.units && this.courseMapUnits) { + // Create a map of unit definition code to Unit instance + const unitMap = new Map(); + this.units.forEach((unit) => { + if (unit.code) { + unitMap.set(unit.code, unit); + } + }); + + // Find actual Unit instances for the course map units + this.courseMapUnits.forEach((courseMapUnit) => { + // Find the unit definition first + const unitDef = this.unitDefinitions?.find((def) => def.id === courseMapUnit.unitId); + if (unitDef && unitDef.code) { + // Then find the corresponding Unit instance + const unit = unitMap.get(unitDef.code); + if (unit && !this.requiredUnits.some((existing) => existing.id === unit.id)) { + this.requiredUnits.push(unit); + } + } + }); } this.initializeMap(); console.log('Course Map Units:', this.courseMapUnits); - console.log('All Required Units (from unit definitions):', this.requiredUnits); + console.log('Required Units (actual Unit instances):', this.requiredUnits); }, error: (err) => { this.errorMessage = 'Error fetching course map units'; @@ -221,15 +239,15 @@ export class CoursemapComponent implements OnInit, OnDestroy { } private initializeMap(): void { - if (this.courseMapUnits && this.unitDefinitions) { + if (this.courseMapUnits && this.requiredUnits) { this.stateService.initializeFromCourseMapUnits( this.courseMapUnits, - this.unitDefinitions, // Pass unit definitions as required units + this.requiredUnits, // Pass actual Unit instances instead of UnitDefinitions this.unitDefinitions || undefined, ); console.log('Course map initialized with:', { courseMapUnits: this.courseMapUnits, - unitDefinitions: this.unitDefinitions, + requiredUnits: this.requiredUnits, }); } } @@ -297,4 +315,17 @@ export class CoursemapComponent implements OnInit, OnDestroy { console.log('Skills summary dialog closed'); }); } + + onShowUnitDetails(unit: CourseUnit): void { + console.log('Opening unit details for:', unit); + const dialogRef = this.dialog.open(UnitDetailsOverlayComponent, { + width: '800px', + maxHeight: '90vh', + data: unit, + }); + + dialogRef.afterClosed().subscribe(() => { + console.log('Unit details dialog closed'); + }); + } } diff --git a/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.html b/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.html index 1d66c9313..5b4e9c66e 100644 --- a/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.html +++ b/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.html @@ -22,6 +22,7 @@

Year {{ year.year }}

(dropEvent)="onTrimesterDrop($event)" (deleteTrimester)="deleteTrimester(tIndex)" (showSkillsSummary)="onShowSkillsSummary($event)" + (showUnitDetails)="onShowUnitDetails($event)" > diff --git a/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.ts b/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.ts index deadd9d00..f1983e050 100644 --- a/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.ts +++ b/src/app/courseflow/states/coursemap/directives/course-year-editor/course-year-editor.component.ts @@ -20,6 +20,7 @@ export class CourseYearEditorComponent { // eslint-disable-next-line @typescript-eslint/no-explicit-any @Output() dropEvent = new EventEmitter(); @Output() showSkillsSummary = new EventEmitter(); + @Output() showUnitDetails = new EventEmitter(); readonly trimesterKeys = TRIMESTER_KEYS; @@ -51,4 +52,8 @@ export class CourseYearEditorComponent { onShowSkillsSummary(unit: CourseUnit): void { this.showSkillsSummary.emit(unit); } + + onShowUnitDetails(unit: CourseUnit): void { + this.showUnitDetails.emit(unit); + } } 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 2ceb0f789..2c8769d12 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 @@ -14,6 +14,7 @@ (removeUnit)="onRemoveUnit(slotIndex)" (toggleCompletion)="onToggleCompletion($event)" (showSkillsSummary)="onShowSkillsSummary($event)" + (showUnitDetails)="onShowUnitDetails($event)" > 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 3f239b2d5..17a7b7d19 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 @@ -22,6 +22,7 @@ export class TrimesterEditorComponent { @Output() dropEvent = new EventEmitter(); @Output() deleteTrimester = new EventEmitter(); @Output() showSkillsSummary = new EventEmitter(); + @Output() showUnitDetails = new EventEmitter(); readonly slotIndices = [0, 1, 2, 3]; @@ -49,6 +50,10 @@ export class TrimesterEditorComponent { this.showSkillsSummary.emit(unit); } + onShowUnitDetails(unit: CourseUnit): void { + this.showUnitDetails.emit(unit); + } + trackBySlotIndex(index: number): number { return index; } diff --git a/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.html b/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.html index 8b13e158b..5d43b21b3 100644 --- a/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.html +++ b/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.html @@ -14,6 +14,7 @@ (removeUnit)="onRemoveUnit()" (toggleCompletion)="onToggleCompletion()" (showSkillsSummary)="onShowSkillsSummary($event)" + (showUnitDetails)="onShowUnitDetails($event)" > diff --git a/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.ts b/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.ts index f096e46ac..ec5cb3273 100644 --- a/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.ts +++ b/src/app/courseflow/states/coursemap/directives/unit-slot/unit-slot.component.ts @@ -31,6 +31,7 @@ export class UnitSlotComponent { @Output() removeUnit = new EventEmitter(); @Output() toggleCompletion = new EventEmitter(); @Output() showSkillsSummary = new EventEmitter(); + @Output() showUnitDetails = new EventEmitter(); get dropListId(): string { return `${this.trimesterKey}-${this.yearIndex}-slot-${this.slotIndex}`; @@ -71,4 +72,8 @@ export class UnitSlotComponent { onShowSkillsSummary(unit: CourseUnit): void { this.showSkillsSummary.emit(unit); } + + onShowUnitDetails(unit: CourseUnit): void { + this.showUnitDetails.emit(unit); + } } From 7645c4b6cb8635c30e32d0beb01840e78150d6cc Mon Sep 17 00:00:00 2001 From: giangnht19 Date: Fri, 12 Sep 2025 20:59:54 +1000 Subject: [PATCH 4/5] fix: remove skill summary section --- .../common/unit-card/unit-card.component.html | 8 -- .../common/unit-card/unit-card.component.ts | 10 --- .../states/coursemap/coursemap.component.html | 2 - .../states/coursemap/coursemap.component.ts | 84 +++---------------- .../course-year-editor.component.html | 2 - .../course-year-editor.component.ts | 12 +-- .../trimester-editor.component.html | 2 - .../trimester-editor.component.ts | 10 --- .../unit-slot/unit-slot.component.html | 2 - .../unit-slot/unit-slot.component.ts | 10 --- 10 files changed, 11 insertions(+), 131 deletions(-) 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 965ecb326..7c946283f 100644 --- a/src/app/courseflow/common/unit-card/unit-card.component.html +++ b/src/app/courseflow/common/unit-card/unit-card.component.html @@ -18,14 +18,6 @@ {{ isCompleted ? 'radio_button_checked' : 'radio_button_unchecked' }} {{ isCompleted ? 'Mark as Incomplete' : 'Mark as Complete' }} - - + + + + +
+
+
+
{{ unit.code }}
+
{{ unit.name }}
+
+ + {{ unit.teachingPeriod.year }} {{ unit.teachingPeriod.period }} + + + {{ unit.active ? 'Active' : 'Inactive' }} + +
+
+
+ +
+ search_off +

No units match the current filters

+
+ +
+ school +

No units available

+
+
+ + +
+
+ {{ selectedUnit.code }} - {{ selectedUnit.name }} +
+ - - +
-
error {{ errorMessage }}
+ +
+ error + {{ errorMessage }} +
+ 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 000e3a863..7ea157379 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 @@ -27,14 +27,196 @@ margin-right: 5px; } -.error { - color: #f44336; - font-size: 1rem; - margin: -18px 0 -8px; - padding: 0 12px; - border-radius: 4px; +.unit-search-container { display: flex; - align-items: center; - gap: 8px; - font-weight: 800; + flex-direction: column; + gap: 1rem; + padding: 1rem; + background: white; + border-radius: 8px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + + .filter-controls { + display: flex; + flex-direction: column; + gap: 1rem; + + .filter-row { + display: flex; + gap: 1rem; + align-items: center; + flex-wrap: wrap; + + .filter-field { + flex: 1; + min-width: 150px; + max-width: 200px; + } + + .search-field { + flex: 2; + min-width: 250px; + } + + .clear-filters-btn { + height: 56px; // Match form field height + min-width: 120px; + } + } + } + + .unit-selection { + .unit-list { + max-height: 400px; + overflow-y: auto; + border: 1px solid #e0e0e0; + border-radius: 4px; + + .unit-item { + padding: 1rem; + border-bottom: 1px solid #f0f0f0; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: #f5f5f5; + } + + &.selected { + background-color: #e3f2fd; + border-left: 4px solid #1976d2; + } + + &:last-child { + border-bottom: none; + } + + .unit-code { + font-weight: 600; + font-size: 1.1rem; + color: #1976d2; + margin-bottom: 0.25rem; + } + + .unit-name { + font-size: 0.95rem; + color: #333; + margin-bottom: 0.5rem; + } + + .unit-details { + display: flex; + gap: 1rem; + font-size: 0.85rem; + + .teaching-period { + color: #666; + background: #f0f0f0; + padding: 0.25rem 0.5rem; + border-radius: 4px; + } + + .status { + padding: 0.25rem 0.5rem; + border-radius: 4px; + font-weight: 500; + + &.active { + background: #e8f5e8; + color: #4caf50; + } + + &.inactive { + background: #ffebee; + color: #f44336; + } + } + } + } + } + + .no-units { + text-align: center; + padding: 2rem; + color: #666; + + mat-icon { + font-size: 3rem; + height: 3rem; + width: 3rem; + color: #ccc; + margin-bottom: 1rem; + } + + p { + margin: 0; + font-size: 1rem; + } + } + } + + .add-unit-section { + display: flex; + justify-content: space-between; + align-items: center; + padding: 1rem; + background: #f9f9f9; + border-radius: 4px; + border: 1px solid #e0e0e0; + + .selected-unit-info { + font-size: 0.95rem; + color: #333; + } + + .add-unit-button { + min-width: 120px; + } + } + + .error { + display: flex; + align-items: center; + gap: 0.5rem; + color: #f44336; + font-size: 0.9rem; + padding: 0.5rem; + background: #ffebee; + border-radius: 4px; + border: 1px solid #ffcdd2; + + mat-icon { + font-size: 1.2rem; + height: 1.2rem; + width: 1.2rem; + } + } +} + +// Responsive design +@media (max-width: 768px) { + .unit-search-container { + .filter-controls { + .filter-row { + flex-direction: column; + align-items: stretch; + + .filter-field, + .search-field { + max-width: none; + width: 100%; + } + } + } + + .add-unit-section { + flex-direction: column; + align-items: stretch; + gap: 1rem; + + .add-unit-button { + width: 100%; + } + } + } } 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 8b1c22846..b932390ee 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,12 +1,22 @@ -import {Component, Input, Output, EventEmitter} from '@angular/core'; +import {Component, Input, Output, EventEmitter, OnInit, OnChanges} from '@angular/core'; import {CommonModule} from '@angular/common'; import {FormsModule} from '@angular/forms'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatInputModule} from '@angular/material/input'; import {MatButtonModule} from '@angular/material/button'; import {MatIconModule} from '@angular/material/icon'; +import {MatSelectModule} from '@angular/material/select'; import {Unit} from 'src/app/api/models/doubtfire-model'; +interface UnitFilters { + level: string; + year: string; + period: string; + active: string; + specialization: string; // Future enhancement - when Unit-Specialization relationship is established + searchText: string; +} + @Component({ selector: 'unit-search', templateUrl: './unit-search.component.html', @@ -19,30 +29,140 @@ import {Unit} from 'src/app/api/models/doubtfire-model'; MatInputModule, MatButtonModule, MatIconModule, + MatSelectModule, ], }) -export class UnitSearchComponent { +export class UnitSearchComponent implements OnInit, OnChanges { @Input() availableUnits!: Unit[]; @Output() unitAdded = new EventEmitter(); - unitCode = ''; + filteredUnits: Unit[] = []; + selectedUnit: Unit | null = null; errorMessage: string | null = null; - onSubmit(): void { - if (!this.unitCode) { - this.errorMessage = 'Please enter a unit code'; + filters: UnitFilters = { + level: '', + year: '', + period: '', + active: '', + specialization: '', // Placeholder for future enhancement + searchText: '', + }; + + availableYears: string[] = []; + availablePeriods: string[] = []; + + ngOnInit(): void { + this.initializeFilterOptions(); + this.applyFilters(); + } + + ngOnChanges(): void { + if (this.availableUnits) { + this.initializeFilterOptions(); + this.applyFilters(); + } + } + + private initializeFilterOptions(): void { + if (!this.availableUnits) return; + + // Extract unique years and periods from available units + const years = new Set(); + const periods = new Set(); + + this.availableUnits.forEach((unit) => { + if (unit.teachingPeriod) { + years.add(unit.teachingPeriod.year); + periods.add(unit.teachingPeriod.period); + } + }); + + this.availableYears = Array.from(years).sort((a, b) => b.localeCompare(a)); // Most recent first + this.availablePeriods = Array.from(periods).sort(); + } + + applyFilters(): void { + if (!this.availableUnits) { + this.filteredUnits = []; return; } - const trimmedCode = this.unitCode.trim().toUpperCase(); - const foundUnit = this.availableUnits.find((unit) => unit.code === trimmedCode); + this.filteredUnits = this.availableUnits.filter((unit) => { + // Level filter (first digit of unit code) + if (this.filters.level) { + const unitLevel = unit.code.charAt(0); + if (unitLevel !== this.filters.level) { + return false; + } + } - if (foundUnit) { - this.unitAdded.emit(foundUnit); - this.unitCode = ''; + // Year filter + if (this.filters.year && unit.teachingPeriod) { + if (unit.teachingPeriod.year !== this.filters.year) { + return false; + } + } + + // Period filter + if (this.filters.period && unit.teachingPeriod) { + if (unit.teachingPeriod.period !== this.filters.period) { + return false; + } + } + + // Active status filter + if (this.filters.active) { + const isActive = this.filters.active === 'true'; + if (unit.active !== isActive) { + return false; + } + } + + // Search text filter + if (this.filters.searchText) { + const searchText = this.filters.searchText.toLowerCase(); + const codeMatch = unit.code.toLowerCase().includes(searchText); + const nameMatch = unit.name.toLowerCase().includes(searchText); + if (!codeMatch && !nameMatch) { + return false; + } + } + + return true; + }); + + // Clear selection if selected unit is no longer in filtered results + if (this.selectedUnit && !this.filteredUnits.some((unit) => unit.id === this.selectedUnit?.id)) { + this.selectedUnit = null; + } + + this.errorMessage = null; + } + + selectUnit(unit: Unit): void { + this.selectedUnit = unit; + this.errorMessage = null; + } + + addSelectedUnit(): void { + if (this.selectedUnit) { + this.unitAdded.emit(this.selectedUnit); + this.selectedUnit = null; this.errorMessage = null; - } else { - this.errorMessage = `Unit code ${trimmedCode} not found in available units`; } } + + clearFilters(): void { + this.filters = { + level: '', + year: '', + period: '', + active: '', + specialization: '', // Placeholder for future enhancement + searchText: '', + }; + this.selectedUnit = null; + this.applyFilters(); + } }