From 3894e5d0284b45f9f0812ec76f51a04de26b7a96 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Thu, 4 Sep 2025 14:56:04 +1000 Subject: [PATCH 1/2] feat: add unit overload, enrich unit cards in slots, and display credit points achieved --- src/app/api/models/unit.ts | 12 +- src/app/api/services/unit.service.ts | 103 +++--- .../credit-points-summary.component.html | 43 +++ .../credit-points-summary.component.scss | 221 ++++++++++++ .../credit-points-summary.component.ts | 53 +++ .../common/unit-card/unit-card.component.html | 8 +- .../common/unit-card/unit-card.component.scss | 12 + .../common/unit-card/unit-card.component.ts | 5 + .../states/coursemap/coursemap.component.html | 13 +- .../states/coursemap/coursemap.component.scss | 341 +----------------- .../states/coursemap/coursemap.component.ts | 29 +- .../overload-warning-dialog.component.html | 15 + .../overload-warning-dialog.component.scss | 74 ++++ .../overload-warning-dialog.component.ts | 23 ++ .../trimester-editor.component.html | 46 ++- .../trimester-editor.component.scss | 81 ++++- .../trimester-editor.component.ts | 48 ++- .../unit-search/unit-search.component.scss | 4 + .../unit-search/unit-search.component.ts | 41 ++- src/app/doubtfire-angular.module.ts | 1 - 20 files changed, 759 insertions(+), 414 deletions(-) create mode 100644 src/app/courseflow/common/credit-points-summary/credit-points-summary.component.html create mode 100644 src/app/courseflow/common/credit-points-summary/credit-points-summary.component.scss create mode 100644 src/app/courseflow/common/credit-points-summary/credit-points-summary.component.ts create mode 100644 src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.html create mode 100644 src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.scss create mode 100644 src/app/courseflow/states/coursemap/directives/trimester-editor/overload-warning-dialog/overload-warning-dialog.component.ts 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'; From a346e7d8c5ff7ef64748a70fde80c888babb31a4 Mon Sep 17 00:00:00 2001 From: Duy Nguyen Date: Tue, 16 Sep 2025 16:50:06 +1000 Subject: [PATCH 2/2] feat: add requisites validation --- .../common/unit-card/unit-card.component.html | 25 ++- .../common/unit-card/unit-card.component.scss | 65 +++++- .../common/unit-card/unit-card.component.ts | 87 +++++++- .../services/course-map-drag-drop.service.ts | 30 +-- .../services/course-map-state.service.ts | 73 ++++++- .../prerequisite-validation.service.ts | 203 ++++++++++++++++++ .../elective-units-list.component.html | 5 +- .../elective-units-list.component.scss | 5 +- .../required-units-list.component.html | 5 +- .../required-units-list.component.scss | 5 +- .../unit-slot/unit-slot.component.html | 8 +- 11 files changed, 472 insertions(+), 39 deletions(-) create mode 100644 src/app/courseflow/services/prerequisite-validation.service.ts 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 3a48544331..4a61b99649 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,33 @@ -
+
- {{ unit.code }} | Level {{ getUnitLevel() }} | {{ unit.credit_points }} points +
+ {{ unit.code }} | Level {{ getUnitLevel() }} | {{ unit.credit_points }} + points +
{{ unit.name }}
+ @if (hasPrerequisiteWarnings) { +
+ @for (warning of validationResult?.warnings; track warning) { +
+ warning + {{ warning }} +
+ } +
+ } + 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 eb691857c1..b269982e9a 100644 --- a/src/app/courseflow/common/unit-card/unit-card.component.scss +++ b/src/app/courseflow/common/unit-card/unit-card.component.scss @@ -17,7 +17,8 @@ text-align: center; transition: background-color 0.2s, - transform 0.1s; + transform 0.1s, + border-color 0.2s; position: relative; margin: 0 auto; box-sizing: border-box; @@ -26,6 +27,68 @@ background-color: #f2f2f2; transform: translateY(-2px); } + + &.has-warnings { + border-color: #ff9800; + background-color: #fff3e0; + + .warning-icon { + color: #ff9800; + } + + &:hover { + background-color: #ffe0b2; + } + } +} + +.unit-header { + font-size: 12px; + color: #666; + margin-bottom: 8px; +} + +.unit-code-section { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + flex-wrap: wrap; + + .warning-icon { + font-size: 16px; + width: 16px; + height: 16px; + } +} + +.unit-name { + font-size: 14px; + font-weight: 500; + line-height: 1.2; +} + +.prerequisite-warnings { + margin-top: 8px; + text-align: left; + + .warning-text { + display: flex; + align-items: center; + font-size: 10px; + color: #e65100; + margin: 2px 0; + line-height: 1.2; + justify-content: center; + + .warning-icon-small { + font-size: 12px; + width: 12px; + height: 12px; + margin-right: 4px; + flex-shrink: 0; + } + } } .unit-header { 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 0797eea7db..3d68e2d843 100644 --- a/src/app/courseflow/common/unit-card/unit-card.component.ts +++ b/src/app/courseflow/common/unit-card/unit-card.component.ts @@ -1,24 +1,99 @@ -import {Component, Input, Output, EventEmitter} from '@angular/core'; +import {Component, Input, Output, EventEmitter, OnInit, OnDestroy} from '@angular/core'; import {CommonModule} from '@angular/common'; import {DragDropModule} from '@angular/cdk/drag-drop'; +import {MatCardModule} from '@angular/material/card'; import {MatIconModule} from '@angular/material/icon'; import {MatButtonModule} from '@angular/material/button'; import {MatMenuModule} from '@angular/material/menu'; -import {CourseUnit} from '../../models/course-map.models'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {Unit} from 'src/app/api/models/doubtfire-model'; +import {CourseMapStateService} from '../../services/course-map-state.service'; +import {PrerequisiteValidationResult} from '../../services/prerequisite-validation.service'; +import {Subject, takeUntil} from 'rxjs'; @Component({ - selector: 'unit-card', + selector: 'f-unit-card', templateUrl: './unit-card.component.html', styleUrls: ['./unit-card.component.scss'], standalone: true, - imports: [CommonModule, DragDropModule, MatIconModule, MatButtonModule, MatMenuModule], + imports: [ + CommonModule, + DragDropModule, + MatCardModule, + MatIconModule, + MatButtonModule, + MatMenuModule, + MatTooltipModule, + ], }) -export class UnitCardComponent { - @Input() unit!: CourseUnit; +export class UnitCardComponent implements OnInit, OnDestroy { + @Input() unit: Unit | null = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any @Input() dragData!: any; @Input() showMenu = false; + @Input() yearIndex?: number; + @Input() trimesterKey?: 'trimester1' | 'trimester2' | 'trimester3'; + @Input() slotIndex?: number; + @Input() showValidation: boolean = false; @Output() removeUnit = new EventEmitter(); + validationResult: PrerequisiteValidationResult | null = null; + private destroy$ = new Subject(); + + constructor(private stateService: CourseMapStateService) {} + + ngOnInit(): void { + if (this.showValidation && this.unit && this.isPlacedInSlot()) { + // Subscribe to validation results changes + this.stateService.validationResults$.pipe(takeUntil(this.destroy$)).subscribe(() => { + this.updateValidationResult(); + }); + + // Initial validation + this.updateValidationResult(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + private isPlacedInSlot(): boolean { + return ( + this.yearIndex !== undefined && + this.trimesterKey !== undefined && + this.slotIndex !== undefined + ); + } + + private updateValidationResult(): void { + if (!this.unit || !this.isPlacedInSlot()) return; + + const year = this.stateService.currentState.years[this.yearIndex!]; + if (!year) return; + + const trimesterNumber = this.stateService.getTrimesterNumber(this.trimesterKey!); + + this.validationResult = this.stateService.getValidationResultForPosition( + this.unit.code, + year.year, + trimesterNumber, + this.slotIndex! + 1, + ); + } + + get hasPrerequisiteWarnings(): boolean { + return (this.validationResult && !this.validationResult.isValid) || false; + } + + get warningTooltip(): string { + if (!this.validationResult || this.validationResult.isValid) { + return ''; + } + return this.validationResult.warnings.join('\n'); + } + onRemoveUnit(): void { this.removeUnit.emit(); } diff --git a/src/app/courseflow/services/course-map-drag-drop.service.ts b/src/app/courseflow/services/course-map-drag-drop.service.ts index 868ac16300..2f20bab9f1 100644 --- a/src/app/courseflow/services/course-map-drag-drop.service.ts +++ b/src/app/courseflow/services/course-map-drag-drop.service.ts @@ -1,16 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import {Injectable} from '@angular/core'; -import { - CdkDragDrop, - DragDropModule, - moveItemInArray, - transferArrayItem, - CdkDrag, -} from '@angular/cdk/drag-drop'; +import {CdkDragDrop, moveItemInArray, transferArrayItem} from '@angular/cdk/drag-drop'; import {CourseMapStateService} from './course-map-state.service'; import {DraggedUnitData, DropResult} from '../models/drag-drop.models'; import {SlotContext, CourseUnit} from '../models/course-map.models'; -import {Unit, UnitDefinition} from 'src/app/api/models/doubtfire-model'; +import {Unit} from 'src/app/api/models/doubtfire-model'; @Injectable({ providedIn: 'root', @@ -25,7 +19,6 @@ export class CourseMapDragDropService { const currentIndex = event.currentIndex; const draggedData = event.item.data as DraggedUnitData; - const unitToMove = draggedData.unit; const targetContainerData = currentContainer.data; // If unit is dragged to the same container @@ -132,13 +125,26 @@ export class CourseMapDragDropService { private placeUnitInEmptySlot( draggedData: DraggedUnitData, targetContext: SlotContext, - sourceList: CourseUnit[], + _sourceList: CourseUnit[], previousIndex: number, sourceIsSlot: boolean, ): DropResult { const currentState = this.stateService.currentState; const {yearIndex, trimesterKey, slotIndex} = targetContext; + // Validate the placement before actually placing the unit + const validationResult = this.stateService.validateUnitPlacement( + draggedData.unit, + yearIndex, + trimesterKey, + slotIndex, + ); + + if (!validationResult.isValid) { + // Log warnings but still allow the placement + console.warn('Unit placed with prerequisite warnings:', validationResult.warnings); + } + // Update the slot const updatedYears = [...currentState.years]; const updatedYear = {...updatedYears[yearIndex]}; @@ -169,7 +175,7 @@ export class CourseMapDragDropService { } } - this.stateService['updateState'](newState); + this.stateService.updateState(newState); return {success: true}; } @@ -227,7 +233,7 @@ export class CourseMapDragDropService { newElectiveUnits.push(existingUnitInSlot as Unit); } - this.stateService['updateState']({ + this.stateService.updateState({ ...currentState, years: updatedYears, requiredUnits: newRequiredUnits, diff --git a/src/app/courseflow/services/course-map-state.service.ts b/src/app/courseflow/services/course-map-state.service.ts index 3289ef30e9..d588c7d9ea 100644 --- a/src/app/courseflow/services/course-map-state.service.ts +++ b/src/app/courseflow/services/course-map-state.service.ts @@ -1,7 +1,11 @@ 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} from 'src/app/api/models/doubtfire-model'; +import { + PrerequisiteValidationService, + PrerequisiteValidationResult, +} from './prerequisite-validation.service'; @Injectable({ providedIn: 'root', // provide 1 instance throughout the entire application -> singleton @@ -28,10 +32,70 @@ export class CourseMapStateService { private stateSubject = new BehaviorSubject(this.initialState); public state$: Observable = this.stateSubject.asObservable(); + constructor(private prerequisiteValidationService: PrerequisiteValidationService) {} + get currentState(): CourseMapState { return this.stateSubject.value; } + // Add a subject to track validation results + private validationResultsSubject = new BehaviorSubject>( + new Map(), + ); + public validationResults$ = this.validationResultsSubject.asObservable(); + + get currentValidationResults(): Map { + return this.validationResultsSubject.value; + } + + // Add validation method + validateUnitPlacement( + unit: Unit, + yearIndex: number, + trimesterKey: 'trimester1' | 'trimester2' | 'trimester3', + slotIndex: number, + ): PrerequisiteValidationResult { + const year = this.currentState.years[yearIndex]; + if (!year) { + return {isValid: false, missingPrerequisites: [], warnings: ['Invalid year']}; + } + + const trimesterNumber = this.getTrimesterNumber(trimesterKey); + + return this.prerequisiteValidationService.validateUnitPlacement( + unit, + year.year, + trimesterNumber, + slotIndex + 1, // Convert to 1-based index + this.currentState, + ); + } + + // Validate all units and update validation results + private validateAllUnits(): void { + const validationResults = this.prerequisiteValidationService.validateAllUnitsInCourseMap( + this.currentState, + ); + this.validationResultsSubject.next(validationResults); + } + + // Override updateState to trigger validation + updateState(newState: CourseMapState): void { + this.stateSubject.next(newState); + // Trigger validation after state update + setTimeout(() => this.validateAllUnits(), 0); + } + + getValidationResultForPosition( + unitCode: string, + year: number, + trimester: number, + slot: number, + ): PrerequisiteValidationResult | null { + const key = `${unitCode}-${year}-${trimester}-${slot}`; + return this.currentValidationResults.get(key) || null; + } + // Year Management addYear(): void { const currentState = this.currentState; @@ -256,13 +320,6 @@ export class CourseMapStateService { return Math.max(0, currentState.maxElectiveUnits - totalElectivesUsed); } - private updateState(newState: CourseMapState): void { - /** - * Update the current state - */ - this.stateSubject.next(newState); - } - private isRequiredUnit(unit: CourseUnit): boolean { /** * Check if a unit is a required unit diff --git a/src/app/courseflow/services/prerequisite-validation.service.ts b/src/app/courseflow/services/prerequisite-validation.service.ts new file mode 100644 index 0000000000..0901a53665 --- /dev/null +++ b/src/app/courseflow/services/prerequisite-validation.service.ts @@ -0,0 +1,203 @@ +import {Injectable} from '@angular/core'; +import {CourseMapState} from '../models/course-map.models'; +import {Unit} from 'src/app/api/models/doubtfire-model'; + +export interface PrerequisiteValidationResult { + isValid: boolean; + missingPrerequisites: Unit[]; + warnings: string[]; +} + +@Injectable({ + providedIn: 'root', +}) +export class PrerequisiteValidationService { + validateUnitPlacement( + unit: Unit, + targetYear: number, + targetTrimester: number, + targetSlot: number, + courseMapState: CourseMapState, + ): PrerequisiteValidationResult { + const result: PrerequisiteValidationResult = { + isValid: true, + missingPrerequisites: [], + warnings: [], + }; + + // Get prerequisites from unit data + const prerequisites = this.getUnitPrerequisites(unit); + + if (prerequisites.length === 0) { + return result; // No prerequisites to validate + } + + // Check each prerequisite + prerequisites.forEach((prereqCode) => { + const prereqUnit = this.findUnitByCode(prereqCode, courseMapState); + + if (!prereqUnit) { + result.warnings.push(`Prerequisite unit ${prereqCode} not found in course map`); + result.isValid = false; + return; + } + + const prereqPosition = this.findUnitPosition(prereqUnit, courseMapState); + + if (!prereqPosition) { + result.missingPrerequisites.push(prereqUnit); + result.warnings.push(`Prerequisite ${prereqCode} has not been placed in the course map`); + result.isValid = false; + } else if ( + !this.isPositionStrictlyBefore(prereqPosition, { + year: targetYear, + trimester: targetTrimester, + slot: targetSlot, + }) + ) { + // Check if prerequisite is in the same trimester or after + if ( + this.isPositionSameOrAfter(prereqPosition, { + year: targetYear, + trimester: targetTrimester, + slot: targetSlot, + }) + ) { + result.warnings.push(`Requisites Apply: ${prereqCode}`); + } else { + result.warnings.push(`Prerequisite ${prereqCode} must be completed before this unit`); + } + result.isValid = false; + } + }); + + return result; + } + + // New method to validate all units in the course map for prerequisite violations + validateAllUnitsInCourseMap( + courseMapState: CourseMapState, + ): Map { + const validationResults = new Map(); + + // Check all placed units + courseMapState.years.forEach((year) => { + const trimesters = ['trimester1', 'trimester2', 'trimester3'] as const; + trimesters.forEach((trimesterKey, trimesterIndex) => { + const trimester = year[trimesterKey]; + if (!trimester) return; + + trimester.forEach((unit, slotIndex) => { + if (!unit) return; + + const validationResult = this.validateUnitPlacement( + unit, + year.year, + trimesterIndex + 1, + slotIndex + 1, + courseMapState, + ); + + if (!validationResult.isValid) { + validationResults.set( + `${unit.code}-${year.year}-${trimesterIndex + 1}-${slotIndex + 1}`, + validationResult, + ); + } + }); + }); + }); + + return validationResults; + } + + private getUnitPrerequisites(unit: Unit): string[] { + // Extract prerequisites from unit data + // Based on the rake file, prerequisites are stored as JSON + try { + if (unit.prerequisites && typeof unit.prerequisites === 'string') { + return JSON.parse(unit.prerequisites); + } + if (Array.isArray(unit.prerequisites)) { + return unit.prerequisites as string[]; + } + return []; + } catch (e) { + console.warn('Error parsing prerequisites for unit', unit.code, e); + return []; + } + } + + private findUnitByCode(code: string, courseMapState: CourseMapState): Unit | null { + // Search in all required units and elective units + const allUnits = [...courseMapState.allRequiredUnits, ...courseMapState.electiveUnits]; + return allUnits.find((unit) => unit.code === code) || null; + } + + private findUnitPosition( + unit: Unit, + courseMapState: CourseMapState, + ): {year: number; trimester: number; slot: number} | null { + for (let yearIndex = 0; yearIndex < courseMapState.years.length; yearIndex++) { + const year = courseMapState.years[yearIndex]; + + const trimesters = ['trimester1', 'trimester2', 'trimester3'] as const; + for (let trimesterIndex = 0; trimesterIndex < trimesters.length; trimesterIndex++) { + const trimester = year[trimesters[trimesterIndex]]; + if (!trimester) continue; + + for (let slotIndex = 0; slotIndex < trimester.length; slotIndex++) { + const slotUnit = trimester[slotIndex]; + if (slotUnit && slotUnit.id === unit.id) { + return { + year: year.year, + trimester: trimesterIndex + 1, + slot: slotIndex + 1, + }; + } + } + } + } + + return null; + } + + private isPositionStrictlyBefore( + prereqPos: {year: number; trimester: number; slot: number}, + targetPos: {year: number; trimester: number; slot: number}, + ): boolean { + // Check if prerequisite position comes before target position + // Prerequisites must be in a previous trimester (not same trimester) + if (prereqPos.year < targetPos.year) return true; + if (prereqPos.year > targetPos.year) return false; + + // Same year, check trimester - prerequisite must be in earlier trimester + if (prereqPos.trimester < targetPos.trimester) return true; + + // Same year and trimester OR later trimester = not valid + return false; + } + + private isPositionSameOrAfter( + prereqPos: {year: number; trimester: number; slot: number}, + targetPos: {year: number; trimester: number; slot: number}, + ): boolean { + // Check if prerequisite is in same trimester or after the target position + if (prereqPos.year > targetPos.year) return true; + if (prereqPos.year < targetPos.year) return false; + + // Same year, check trimester + if (prereqPos.trimester >= targetPos.trimester) return true; + + return false; + } + + // Helper method to check if two units have prerequisite relationship issues + private hasPrerequisiteConflict(unit1: Unit, unit2: Unit): boolean { + const unit1Prerequisites = this.getUnitPrerequisites(unit1); + const unit2Prerequisites = this.getUnitPrerequisites(unit2); + + // Check if unit1 requires unit2 OR unit2 requires unit1 + return unit1Prerequisites.includes(unit2.code) || unit2Prerequisites.includes(unit1.code); + } +} diff --git a/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.html b/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.html index 0044d55bcf..cc49f4763e 100644 --- a/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.html +++ b/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.html @@ -13,11 +13,12 @@

Elective Units

(cdkDropListDropped)="onDrop($event)" class="units-list" > - - +
diff --git a/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.scss b/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.scss index b2feaea528..c74e579a91 100644 --- a/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.scss +++ b/src/app/courseflow/states/coursemap/directives/elective-units-list/elective-units-list.component.scss @@ -35,9 +35,10 @@ margin-bottom: 0; } -unit-card { +f-unit-card { width: auto; height: auto; min-width: 100px; - margin: 12px 0 8px; + margin: 8px 0; + display: block; } diff --git a/src/app/courseflow/states/coursemap/directives/required-units-list/required-units-list.component.html b/src/app/courseflow/states/coursemap/directives/required-units-list/required-units-list.component.html index f569aca35d..a0506fabe4 100644 --- a/src/app/courseflow/states/coursemap/directives/required-units-list/required-units-list.component.html +++ b/src/app/courseflow/states/coursemap/directives/required-units-list/required-units-list.component.html @@ -10,12 +10,13 @@

Required Units

(cdkDropListDropped)="onDrop($event)" class="units-list" > - - +
diff --git a/src/app/courseflow/states/coursemap/directives/required-units-list/required-units-list.component.scss b/src/app/courseflow/states/coursemap/directives/required-units-list/required-units-list.component.scss index e2669cb756..d2bf2c94de 100644 --- a/src/app/courseflow/states/coursemap/directives/required-units-list/required-units-list.component.scss +++ b/src/app/courseflow/states/coursemap/directives/required-units-list/required-units-list.component.scss @@ -31,9 +31,10 @@ margin-bottom: 0; } -unit-card { +f-unit-card { width: auto; height: auto; min-width: 100px; - margin: 12px 0 8px; + margin: 8px 0; + display: block; } 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 64de599fc8..38913c221e 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 @@ -6,12 +6,16 @@ [cdkDropListData]="dropListData" (cdkDropListDropped)="onDrop($event)" > - - +