diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index ae030bcc3e..4df00b3cd4 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -36,7 +36,7 @@ import {MatSnackBarModule} from '@angular/material/snack-bar'; import {MatPaginatorModule} from '@angular/material/paginator'; import {MatTooltipModule} from '@angular/material/tooltip'; import {MatSlideToggleModule} from '@angular/material/slide-toggle'; -import {MatChipListbox, MatChipsModule} from '@angular/material/chips'; +import {MatChipsModule} from '@angular/material/chips'; import {MatGridListModule} from '@angular/material/grid-list'; import {PdfViewerModule} from 'ng2-pdf-viewer'; import {UIRouterUpgradeModule} from '@uirouter/angular-hybrid'; @@ -213,6 +213,8 @@ import {TaskAssessmentCardComponent} from './projects/states/dashboard/directive import {TaskSubmissionCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component'; import {TaskDashboardComponent} from './projects/states/dashboard/directives/task-dashboard/task-dashboard.component'; import {InboxComponent} from './units/states/tasks/inbox/inbox.component'; +import {PortfoliosComponent} from './units/states/portfolios/portfolios.component'; +import {AjsProjectProgressDashboardComponent} from './units/states/portfolios/ajs-project-progress-dashboard.component'; import {ProjectProgressBarComponent} from './common/project-progress-bar/project-progress-bar.component'; import {TeachingPeriodListComponent} from './admin/states/teaching-periods/teaching-period-list/teaching-period-list.component'; import {FChipComponent} from './common/f-chip/f-chip.component'; @@ -454,6 +456,8 @@ const GANTT_CHART_CONFIG = { TaskSubmissionCardComponent, TaskDashboardComponent, InboxComponent, + PortfoliosComponent, + AjsProjectProgressDashboardComponent, ProjectProgressBarComponent, TeachingPeriodListComponent, CreateNewUnitModal, @@ -683,7 +687,6 @@ const GANTT_CHART_CONFIG = { NgxGanttModule, MatSidenavModule, MonacoEditorModule.forRoot(), - MatChipListbox, ], }) diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index ca57426fd2..637af2ed6c 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -203,6 +203,7 @@ import {FooterComponent} from './common/footer/footer.component'; import {TaskAssessmentCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-assessment-card/task-assessment-card.component'; import {TaskSubmissionCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component'; import {InboxComponent} from './units/states/tasks/inbox/inbox.component'; +import {PortfoliosComponent} from './units/states/portfolios/portfolios.component'; import {TaskDefinitionEditorComponent} from './units/states/edit/directives/unit-tasks-editor/task-definition-editor/task-definition-editor.component'; import {UnitAnalyticsComponent} from './units/states/analytics/unit-analytics-route.component'; import {UnitTaskEditorComponent} from './units/states/edit/directives/unit-tasks-editor/unit-task-editor.component'; @@ -417,6 +418,7 @@ DoubtfireAngularJSModule.directive( downgradeComponent({component: TasksViewerComponent}), ); DoubtfireAngularJSModule.directive('fInbox', downgradeComponent({component: InboxComponent})); +DoubtfireAngularJSModule.directive('fPortfolios', downgradeComponent({component: PortfoliosComponent})); DoubtfireAngularJSModule.directive( 'fTaskDueCard', downgradeComponent({component: TaskDueCardComponent}), diff --git a/src/app/projects/project-progress-dashboard/project-progress-dashboard.coffee b/src/app/projects/project-progress-dashboard/project-progress-dashboard.coffee index d28a0d31b5..fcf826733d 100644 --- a/src/app/projects/project-progress-dashboard/project-progress-dashboard.coffee +++ b/src/app/projects/project-progress-dashboard/project-progress-dashboard.coffee @@ -10,17 +10,31 @@ angular.module('doubtfire.projects.project-progress-dashboard',[]) .directive('projectProgressDashboard', -> restrict: 'E' templateUrl: 'projects/project-progress-dashboard/project-progress-dashboard.tpl.html' + # Explicit bindings for use inside upgraded Angular hosts (f-portfolios). Link copies from + # $parent when attributes are omitted so legacy templates without project="..." still work. + scope: + project: '<' + unit: '<' controller: ($scope, $state, $rootScope, $stateParams, newProjectService, alertService, gradeService, newTaskService, listenerService) -> - if $stateParams.projectId? - $scope.studentProjectId = $stateParams.projectId - else if $scope.project? - $scope.studentProjectId = $scope.project.id - $scope.grades = gradeService.grades $scope.currentVisualisation = 'burndown' + $scope.taskStats = {} + + updateTaskCompletionStats = -> + return unless $scope.project? + $scope.taskStats.numberOfTasksCompleted = $scope.project.tasksByStatus(newTaskService.completeStatus).length + $scope.taskStats.numberOfTasksRemaining = $scope.project.activeTasks().length - $scope.taskStats.numberOfTasksCompleted + + syncStudentProjectId = -> + if $stateParams.projectId? + $scope.studentProjectId = $stateParams.projectId + else if $scope.project? + $scope.studentProjectId = $scope.project.id + $scope.chooseGrade = (idx) -> + return unless $scope.project? $scope.project.targetGrade = idx newProjectService.update($scope.project).subscribe( (response) -> @@ -29,18 +43,19 @@ angular.module('doubtfire.projects.project-progress-dashboard',[]) updateTaskCompletionStats() $scope.taskCount = -> - $scope.unit.taskDefinitionCount - - $scope.taskStats = {} - - # Update move to task and project... - updateTaskCompletionStats = -> - $scope.taskStats.numberOfTasksCompleted = $scope.project.tasksByStatus(newTaskService.completeStatus).length - $scope.taskStats.numberOfTasksRemaining = $scope.project.activeTasks().length - $scope.taskStats.numberOfTasksCompleted + $scope.unit?.taskDefinitionCount $scope.$on 'TaskStatusUpdated', -> updateTaskCompletionStats() + $scope.$watch 'project', (project) -> + return unless project? + syncStudentProjectId() + updateTaskCompletionStats() - updateTaskCompletionStats() + link: (scope, _el, _attrs) -> + unless scope.project? + scope.project = scope.$parent.project if scope.$parent? + unless scope.unit? + scope.unit = scope.$parent.unit if scope.$parent? ) diff --git a/src/app/projects/states/index/global-state.service.ts b/src/app/projects/states/index/global-state.service.ts index 6d3cdb12d2..c28824121b 100644 --- a/src/app/projects/states/index/global-state.service.ts +++ b/src/app/projects/states/index/global-state.service.ts @@ -2,7 +2,7 @@ import {Inject, Injectable, OnDestroy} from '@angular/core'; import {MediaObserver} from 'ng-flex-layout'; import {UIRouter} from '@uirouter/angular'; import {EntityCache} from 'ngx-entity-service'; -import {BehaviorSubject, Observable, Subject, skip, take} from 'rxjs'; +import {BehaviorSubject, Observable, Subject, catchError, of, skip, take} from 'rxjs'; import { CampusService, LearningOutcomeService, @@ -122,9 +122,7 @@ export class GlobalStateService implements OnDestroy { setTimeout(() => { // Try to login using the refresh token this.authenticationService.attemptLoginUsingRefreshToken((result: boolean) => { - if (result) { - this.loadGlobals(); - } else { + if (!result) { // Loading is finshed... this.isLoadingSubject.next(false); @@ -133,6 +131,7 @@ export class GlobalStateService implements OnDestroy { this.router.stateService.go('sign_in'); } } + // On success, loadGlobals() runs from AuthenticationService.setupUserFromResponse }); }, 100); @@ -238,29 +237,35 @@ export class GlobalStateService implements OnDestroy { }); if (this.userService.currentUser.isStaff) { + // Global GLO / feedback-chip endpoints are optional: many API builds do not mount + // GET /api/global/outcomes or /api/global/feedback_chips yet. Failing here should not + // block login or spam error toasts; task-level data still loads from unit routes. this.learningOutcomeService .query({}, {endpointFormat: LearningOutcomeService.globalEndpoint}) + .pipe( + catchError((err: unknown) => { + console.warn('Global learning outcomes (GLO) bootstrap skipped:', err); + return of([]); + }), + ) .subscribe({ next: (_response) => { subscriber.next(true); }, - error: (_response) => { - this.alerts.error('Unable to access service. Failed loading GLOs.', 6000); - }, }); this.feedbackTemplateService .query({}, {endpointFormat: FeedbackTemplateService.globalEndpoint}) + .pipe( + catchError((err: unknown) => { + console.warn('Global GLO feedback templates bootstrap skipped:', err); + return of([]); + }), + ) .subscribe({ next: (_response) => { subscriber.next(true); }, - error: (_response) => { - this.alerts.error( - 'Unable to access service. Failed loading GLO feedback templates.', - 6000, - ); - }, }); } diff --git a/src/app/sessions/states/sign-in/sign-in.component.ts b/src/app/sessions/states/sign-in/sign-in.component.ts index 8f047489d4..91c6de68cc 100644 --- a/src/app/sessions/states/sign-in/sign-in.component.ts +++ b/src/app/sessions/states/sign-in/sign-in.component.ts @@ -206,7 +206,7 @@ export class SignInComponent implements OnInit { * Perform the actions needed when the user successfully signs in. */ private actionSignInSuccess(): void { - this.globalState.loadGlobals(); + // loadGlobals() already runs inside AuthenticationService.setupUserFromResponse after sign-in this.state.go('welcome'); } @@ -246,7 +246,6 @@ export class SignInComponent implements OnInit { this.authService.signIn(signInCredentials).subscribe({ next: () => { if (this.isLtiLogin) { - this.globalState.loadGlobals(); const params = getUrlParams(document.location.href); this.state.go('lti', { ltik: params.ltik, diff --git a/src/app/units/states/portfolios/ajs-project-progress-dashboard.component.ts b/src/app/units/states/portfolios/ajs-project-progress-dashboard.component.ts new file mode 100644 index 0000000000..e71e55855e --- /dev/null +++ b/src/app/units/states/portfolios/ajs-project-progress-dashboard.component.ts @@ -0,0 +1,32 @@ +import { Component, ElementRef, Inject, Input, Injector, OnInit, Optional } from '@angular/core'; +import { UpgradeComponent } from '@angular/upgrade/static'; +import { Project } from 'src/app/api/models/project'; +import { Unit } from 'src/app/api/models/unit'; +import { visualisations } from 'src/app/ajs-upgraded-providers'; + +/** + * Hosts the AngularJS `projectProgressDashboard` directive inside Angular templates + * (same charts and target-grade UI as the legacy portfolios “View Progress” tab). + */ +@Component({ + selector: 'f-ajs-project-progress-dashboard', + template: '', +}) +export class AjsProjectProgressDashboardComponent extends UpgradeComponent implements OnInit { + @Input() project: Project; + @Input() unit: Unit; + + constructor( + elementRef: ElementRef, + injector: Injector, + @Optional() @Inject(visualisations) private readonly visualisationApi: { refreshAll?: () => void } | null, + ) { + super('projectProgressDashboard', elementRef, injector); + } + + override ngOnInit(): void { + super.ngOnInit(); + // Burndown / pie use legacy visualisation lifecycle; match UnitPortfoliosStateCtrl.refreshCharts. + setTimeout(() => this.visualisationApi?.refreshAll?.(), 0); + } +} diff --git a/src/app/units/states/portfolios/portfolios.coffee b/src/app/units/states/portfolios/portfolios.coffee index 62c70a04f3..86193d5628 100644 --- a/src/app/units/states/portfolios/portfolios.coffee +++ b/src/app/units/states/portfolios/portfolios.coffee @@ -7,154 +7,9 @@ angular.module('doubtfire.units.states.portfolios', []) parent: 'units/index' url: '/students/portfolios' templateUrl: "units/states/portfolios/portfolios.tpl.html" - controller: "UnitPortfoliosStateCtrl" data: task: "Student Portfolios" pageTitle: "_Home_" roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] } ) -.controller("UnitPortfoliosStateCtrl", ($scope, alertService, analyticsService, gradeService, newProjectService, Visualisation, newTaskService, fileDownloaderService, newUserService, D2lTransferModal, newUnitService, sidekiqProgressModalService) -> - # TODO: (@alexcu) Break this down into smaller directives/substates - - $scope.unit.loadD2lMapping().subscribe() - - $scope.downloadGrades = -> fileDownloaderService.downloadFile($scope.unit.gradesUrl, "#{$scope.unit.code}-grades.csv") - - $scope.downloadPortfolios = -> - newUnitService.zipPortfolios($scope.unit).subscribe({ - next: (newJob) -> - sidekiqProgressModalService.show("Downloading Portfolios: " + $scope.unit.code, newJob.id).subscribe({ - next: (job) -> - fileDownloaderService.downloadFile($scope.unit.portfoliosUrl, "#{$scope.unit.code}-portfolios.zip") - error: (message) -> alertService.error(message, 6000) - }) - error: (message) -> alertService.error(message, 6000) - }) - - - $scope.studentFilter = 'allStudents' - $scope.portfolioFilter = 'withPortfolio' - - $scope.statusClass = newTaskService.statusClass - $scope.statusText = newTaskService.statusText - - refreshCharts = Visualisation.refreshAll - - # - # Sets the active tab - # - $scope.setActiveTab = (tab) -> - # Do nothing if we're switching to the same tab - return if tab is $scope.activeTab - $scope.activeTab?.active = false - $scope.activeTab = tab - $scope.activeTab.active = true - - if $scope.activeTab == $scope.tabs.viewProgress - refreshCharts() - - # - # Active task tab group - # - $scope.tabs = - selectStudent: - title: "Select Student" - subtitle: "Select the student to assess" - seq: 0 - viewProgress: - title: "View Progress" - subtitle: "See the progress of the student" - seq: 1 - viewStaffNotes: - title: "View Staff Notes" - subtitle: "See notes of the student add by staff" - seq: 2 - viewPortfolio: - title: "View Portfolio" - subtitle: "See the portfolio of the student" - seq: 3 - assessPortfolio: - title: "Assess Portfolio" - subtitle: "Enter a grade for the student" - seq: 4 - - $scope.setActiveTab($scope.tabs.selectStudent) - - $scope.tutor = newUserService.currentUser - - $scope.search = "" - - # Pagination details - $scope.currentPage = 1 - $scope.maxSize = 5 - $scope.pageSize = 10 - - $scope.filterOptions = {selectedGrade: -1} - $scope.gradeValues = gradeService.gradeValues - $scope.grades = gradeService.grades - $scope.gradeAcronyms = gradeService.gradeAcronyms - - $scope.selectedStudent = null - - $scope.gradeResults = [ - { - name: 'Fail', - scores: [ 0, 10, 20, 30, 40, 44 ] - } - { - name: 'Pass', - scores: [ 50, 53, 55, 57 ] - } - { - name: 'Credit', - scores: [ 60, 63, 65, 67 ] - } - { - name: 'Distinction', - scores: [ 70, 73, 75, 77 ] - } - { - name: 'High Distinction', - scores: [ 80, 83, 85, 87 ] - } - { - name: 'High Distinction', - scores: [ 90, 93, 95, 97, 100 ] - } - ] - - $scope.editingRationale = false - - $scope.toggleEditRationale = -> - $scope.editingRationale = !$scope.editingRationale - - - analyticsService.watchEvent $scope, 'studentFilter', 'Teacher View - Grading Tab' - analyticsService.watchEvent $scope, 'sortOrder', 'Teacher View - Grading Tab' - analyticsService.watchEvent $scope, 'currentPage', 'Teacher View - Grading Tab', 'Selected Page' - - $scope.selectStudent = (student) -> - $scope.selectedStudent = student - $scope.project = null - newProjectService.loadProject(student, $scope.unit).subscribe({ - next: (project) -> - $scope.project = project - $scope.project.preloadedUrl = $scope.project.portfolioUrl() - error: (message) -> alertService.error( message, 6000) - }) - - $scope.hasD2lMapping = -> - $scope.unit.hasD2lMapping() - - $scope.transferToD2L = -> - D2lTransferModal.open($scope.unit) - - $scope.openProject = ($event, project) -> - $event.stopPropagation() - # HACK: avoids using window.open() to prevent AngularJS error - link = document.createElement('a') - link.href = "/projects/#{project.id}/dashboard/?tutor=true" - link.target = '_blank' - link.click() -) diff --git a/src/app/units/states/portfolios/portfolios.component.html b/src/app/units/states/portfolios/portfolios.component.html new file mode 100644 index 0000000000..71316507f2 --- /dev/null +++ b/src/app/units/states/portfolios/portfolios.component.html @@ -0,0 +1,339 @@ +
+ + + +
{{ tabs.selectStudent.title }}
+
+ + + +
+
+
+ + + + + + + + + + + + + + + + + + + + All + + + + +
+ +
+ + Search + + +
+ + + +
+
+
+ +
+

No portfolios found

+
+ +

+ Showing {{ totalItems }} of {{ totalEnrolledStudents }} students enrolled. +

+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Student + {{ sortReverse ? '↓' : '↑' }} + + {{ row.student.studentId || row.student.username }} + + Name + {{ sortReverse ? '↓' : '↑' }} + + {{ row.student.name }} + + Tutor + + {{ row.tutorNames() }} + + Tutorial + + {{ row.shortTutorialDescription() }} + + Target + + + {{ gradeAcronym(row.targetGrade) }} + + + Submitted as + + + {{ gradeAcronym(row.submittedGrade) }} + + + Stats + +
+
+
+
Portfolio? + {{ (row.hasPortfolio || (row.portfolioStatus ?? 0) > 0) ? 'Yes' : 'No' }} + Grade + {{ row.grade ?? '-' }} +
+ + +
+
+
+
+ + + +
+ +

Loading student project…

+
+ +
+
+

Review Progress of {{ selectedStudent?.student.name }}

+

Progress through the unit's tasks.

+
+
+ +
+
+
+
+ + + +
+ +

Loading student project…

+
+ +
+
+

Review Staff Notes for {{ selectedStudent?.student.name }}

+

View internal notes recorded by staff about the student.

+
+
+ +
+
+
+
+ + + +
+ +

Loading student project…

+
+ +
+
+

Review Portfolio of {{ selectedStudent?.student.name }}

+

View or download portfolio for assessment.

+
+ +
+
+

No Portfolio Submitted

+
+ +
+ +
+
+
+
+
+ + + +
+ +

Loading student project…

+
+ +
+
+

Grade for {{ selectedStudent?.student.name }}

+

Assign grade and rationale for this work.

+
+ +
+ + Grade rationale + + + +
+
+
{{ bucket.name }}
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+ +
+

Select student to view portfolio and assign grade.

+
+
+ diff --git a/src/app/units/states/portfolios/portfolios.component.scss b/src/app/units/states/portfolios/portfolios.component.scss new file mode 100644 index 0000000000..0d59565aed --- /dev/null +++ b/src/app/units/states/portfolios/portfolios.component.scss @@ -0,0 +1,194 @@ +#student-portfolios { + .toolbar { + margin-bottom: 1rem; + } + + .toolbar-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: space-between; + } + + .filter-group { + display: flex; + gap: 0.25rem; + align-items: center; + } + + .search-row { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: center; + justify-content: space-between; + margin-top: 0.75rem; + } + + .toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + justify-content: flex-end; + } + + .download-staff-notes-wrap { + display: inline-flex; + align-items: center; + } + + .staff-notes-panel-body { + min-height: 280px; + } + + .download-btn { + height: 56px; + } + + .filtered-count-text { + margin: 0.25rem 0 0.75rem; + color: rgba(0, 0, 0, 0.65); + } + + .empty-state { + padding: 1.25rem 0; + text-align: center; + color: rgba(0, 0, 0, 0.6); + } + + table.f-table { + width: 100%; + } + + .clickable { + cursor: pointer; + user-select: none; + } + + .clickable-row { + cursor: pointer; + } + + .selected-row { + background-color: rgba(63, 81, 181, 0.1); // material primary-ish + } + + .sort-indicator { + font-size: 0.9em; + opacity: 0.75; + margin-left: 0.5rem; + } + + .grade-icon-circle { + width: 2.25em; + height: 2.25em; + border-radius: 100%; + color: #fff; + display: inline-flex; + align-items: center; + justify-content: center; + font-weight: 100; + font-size: 0.95em; + } + + .grade-icon-circle.small { + width: 1.85em; + height: 1.85em; + font-size: 0.85em; + } + + .task-progress-stack { + width: 120px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: flex-start; + height: 12px; + border-radius: 999px; + overflow: hidden; + background-color: #eaeaea; + } + + .task-progress-segment { + height: 100%; + min-width: 1px; + } + + // Progress panel + .panel { + width: 100%; + padding: 1rem 0; + } + + .panel-header { + padding: 0.25rem 1rem; + } + + .panel-title { + margin: 0; + font-weight: 800; + font-size: 1.1rem; + } + + .panel-subtitle { + margin: 0.25rem 0 0; + opacity: 0.7; + } + + .panel-body { + padding: 0.25rem 1rem 1rem; + } + + .progress-panel-body { + overflow-x: auto; + padding-top: 0.5rem; + } + + // Assess panel + .alignment-rater { + display: flex; + flex-direction: column; + gap: 1.25rem; + } + + .grade-buckets { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .grade-bucket { + display: flex; + gap: 0.75rem; + align-items: baseline; + flex-wrap: wrap; + } + + .grade-bucket-title { + width: 10rem; + font-weight: 800; + opacity: 0.9; + } + + .grade-bucket-scores { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + } + + .grade-selected { + background-color: rgba(63, 81, 181, 0.2); + } + + .actions { + display: flex; + justify-content: flex-end; + } + + .save-btn { + min-width: 8rem; + } +} + diff --git a/src/app/units/states/portfolios/portfolios.component.ts b/src/app/units/states/portfolios/portfolios.component.ts new file mode 100644 index 0000000000..d666ab3e34 --- /dev/null +++ b/src/app/units/states/portfolios/portfolios.component.ts @@ -0,0 +1,429 @@ +import { Component, Inject, Input, OnChanges, OnDestroy, OnInit, Optional, SimpleChanges, ViewChild } from '@angular/core'; +import { MatPaginator, PageEvent } from '@angular/material/paginator'; +import { MatTableDataSource } from '@angular/material/table'; +import { GradeService } from 'src/app/common/services/grade.service'; +import { ProjectService } from 'src/app/api/services/project.service'; +import { Unit } from 'src/app/api/models/unit'; +import { Project } from 'src/app/api/models/project'; +import { UnitRole } from 'src/app/api/models/unit-role'; +import { User } from 'src/app/api/models/doubtfire-model'; +import { UserService } from 'src/app/api/services/user.service'; +import { FileDownloaderService } from 'src/app/common/file-downloader/file-downloader.service'; +import { AlertService } from 'src/app/common/services/alert.service'; +import { TaskService } from 'src/app/api/services/task.service'; +import { Subscription } from 'rxjs'; +import { visualisations } from 'src/app/ajs-upgraded-providers'; + +type StudentTabKey = + | 'selectStudent' + | 'viewProgress' + | 'viewStaffNotes' + | 'viewPortfolio' + | 'assessPortfolio'; + +type GradeBucket = { name: string; scores: number[] }; + +@Component({ + selector: 'f-portfolios', + templateUrl: './portfolios.component.html', + styleUrls: ['./portfolios.component.scss'], +}) +export class PortfoliosComponent implements OnInit, OnChanges, OnDestroy { + @Input() unit: Unit; + @Input() unitRole: UnitRole; + + @ViewChild(MatPaginator) paginator: MatPaginator; + + public tutor: User; + + public readonly tabs = { + selectStudent: { title: 'Select Student', subtitle: 'Select the student to assess', key: 'selectStudent' as StudentTabKey }, + viewProgress: { title: 'View Progress', subtitle: 'See the progress of the student', key: 'viewProgress' as StudentTabKey }, + viewStaffNotes: { + title: 'View Staff Notes', + subtitle: 'See notes about the student added by staff', + key: 'viewStaffNotes' as StudentTabKey, + }, + viewPortfolio: { title: 'View Portfolio', subtitle: 'See the portfolio of the student', key: 'viewPortfolio' as StudentTabKey }, + assessPortfolio: { title: 'Assess Portfolio', subtitle: 'Enter a grade for the student', key: 'assessPortfolio' as StudentTabKey }, + }; + + /** Tab index order must match `mat-tab` order in the template. */ + private readonly tabOrder: StudentTabKey[] = [ + 'selectStudent', + 'viewProgress', + 'viewStaffNotes', + 'viewPortfolio', + 'assessPortfolio', + ]; + + public activeTab: StudentTabKey = 'selectStudent'; + + public studentFilter: 'allStudents' | 'myStudents' = 'allStudents'; + public portfolioFilter: 'withPortfolio' | 'allStudents' = 'withPortfolio'; + public filterOptions: { selectedGrade: number } = { selectedGrade: -1 }; + + public search = ''; + + public gradeValues: number[] = []; + public gradeServiceGrades: Record = {}; + public gradeResults: GradeBucket[] = []; + + public selectedStudent: Project | null = null; + public project: Project | null = null; + public loadingProject = false; + + public readonly dataSource = new MatTableDataSource([]); + + public displayedColumns: string[] = []; + public readonly baseDisplayedColumns: string[] = [ + 'student', + 'name', + 'tutor', + 'tutorial', + 'targetGrade', + 'submittedGrade', + 'progress', + 'grade', + ]; + public readonly hasPortfolioColumn = 'hasPortfolio'; + + public sortOrder: + | 'studentId' + | 'name' + | 'tutorNames' + | 'tutorial' + | 'targetGrade' + | 'submittedGrade' + | 'orderScale' + | 'hasPortfolio' + | 'grade' = 'name'; + public sortReverse = false; + + public pageSize = 10; + public pageSizeOptions = [5, 10, 25]; + public pageIndex = 0; + public totalItems = 0; + public totalEnrolledStudents = 0; + + private subscriptions: Subscription[] = []; + + private sortedRows: Project[] = []; + + constructor( + public gradeService: GradeService, + private projectService: ProjectService, + private userService: UserService, + private fileDownloader: FileDownloaderService, + private alerts: AlertService, + private taskService: TaskService, + @Optional() @Inject(visualisations) private readonly visualisationApi: { refreshAll?: () => void } | null, + ) {} + + ngOnInit(): void { + this.tutor = this.userService.currentUser; + + this.gradeValues = this.gradeService.gradeValues.slice(); + this.gradeServiceGrades = this.gradeService.grades as unknown as Record; + + // Matches the legacy template layout. + this.gradeResults = [ + { name: 'Fail', scores: [0, 10, 20, 30, 40, 44] }, + { name: 'Pass', scores: [50, 53, 55, 57] }, + { name: 'Credit', scores: [60, 63, 65, 67] }, + { name: 'Distinction', scores: [70, 73, 75, 77] }, + { name: 'High Distinction', scores: [80, 83, 85, 87] }, + { name: 'High Distinction', scores: [90, 93, 95, 97, 100] }, + ]; + + this.applyFiltersAndSort(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['unit']?.currentValue) { + const prev = changes['unit'].previousValue as Unit | undefined; + const cur = changes['unit'].currentValue as Unit; + if (!prev || prev.id !== cur.id) { + cur.loadD2lMapping?.().subscribe(); + } + } + if (changes['unit'] || changes['unitRole']) { + this.applyFiltersAndSort(); + } + } + + ngOnDestroy(): void { + this.subscriptions.forEach((s) => s.unsubscribe()); + } + + private updateDisplayedColumns(): void { + const includeHasPortfolio = this.portfolioFilter === 'allStudents'; + this.displayedColumns = [...this.baseDisplayedColumns]; + if (includeHasPortfolio) { + // Keep order aligned with the legacy table. + this.displayedColumns = [ + 'student', + 'name', + 'tutor', + 'tutorial', + 'targetGrade', + 'submittedGrade', + 'progress', + 'hasPortfolio', + 'grade', + ]; + } else { + this.displayedColumns = [ + 'student', + 'name', + 'tutor', + 'tutorial', + 'targetGrade', + 'submittedGrade', + 'progress', + 'grade', + ]; + } + } + + public onTabChange(tabKey: StudentTabKey): void { + this.activeTab = tabKey; + if (tabKey === 'viewProgress' && this.project) { + this.project.refreshBurndownChartData(); + // Legacy charts (nv/d3) need a refresh after the tab body is shown. + setTimeout(() => this.visualisationApi?.refreshAll?.(), 0); + } + } + + public onTabChanged(index: number): void { + const tabKey = this.tabOrder[index] ?? 'selectStudent'; + this.onTabChange(tabKey); + } + + public onDownloadGrades(): void { + if (!this.unit) return; + this.fileDownloader.downloadFile(this.unit.gradesUrl, `${this.unit.code}-grades.csv`); + } + + public onDownloadPortfolios(): void { + if (!this.unit) return; + this.fileDownloader.downloadFile(this.unit.portfoliosUrl, `${this.unit.code}-portfolios.zip`); + } + + public setActiveTab(tabKey: StudentTabKey): void { + if (this.activeTab === tabKey) return; + this.activeTab = tabKey; + this.onTabChange(tabKey); + } + + public selectStudent(student: Project): void { + this.selectedStudent = student; + this.project = null; + this.loadingProject = true; + + if (!this.unit || !this.selectedStudent) return; + + this.subscriptions.push( + this.projectService.loadProject(this.selectedStudent, this.unit).subscribe({ + next: (loaded) => { + this.project = loaded; + this.loadingProject = false; + // If the user already navigated to a tab that depends on the project, + // ensure derived visuals are refreshed after load. + if (this.activeTab === 'viewProgress') { + this.project.refreshBurndownChartData(); + } + }, + error: (message) => { + this.alerts.error(message, 6000); + this.loadingProject = false; + }, + }), + ); + } + + public trackByProjectId(_index: number, row: Project): number { + return row?.id; + } + + private normalizeSearchText(text: string): string { + return text?.trim().toLowerCase() ?? ''; + } + + private matchesSearch(project: Project): boolean { + const q = this.normalizeSearchText(this.search); + if (!q) return true; + return project.matches(q); + } + + private applyFiltersAndSort(): void { + if (!this.unit || !this.unit.students) { + this.dataSource.data = []; + this.totalItems = 0; + this.updateDisplayedColumns(); + return; + } + + let rows = this.unit.students.slice(); + this.totalEnrolledStudents = this.unit.students.length; + + // Portfolio filter (legacy: hasPortfolio includes in-progress portfolios too). + rows = rows.filter((p) => { + if (this.portfolioFilter === 'allStudents') return true; + return (p.hasPortfolio || (p.portfolioStatus ?? 0) > 0) === true; + }); + + // Student filter. + rows = rows.filter((p) => { + if (this.studentFilter === 'allStudents') return true; + return p.hasTutor(this.tutor); + }); + + // Submitted grade filter. + rows = rows.filter((p) => { + if (this.filterOptions.selectedGrade === -1) return true; + return p.submittedGrade === this.filterOptions.selectedGrade; + }); + + // Search filter. + rows = rows.filter((p) => this.matchesSearch(p)); + + this.sortedRows = this.sortStudents(rows); + + this.totalItems = this.sortedRows.length; + this.updateDisplayedColumns(); + + this.pageIndex = 0; + this.repaginateFromSorted(); + } + + private sortStudents(rows: Project[]): Project[] { + const dir = this.sortReverse ? -1 : 1; + + const getSortValue = (p: Project): number | string => { + switch (this.sortOrder) { + case 'studentId': + return p.student?.studentId || p.student?.username || ''; + case 'name': + return p.student?.name || ''; + case 'tutorNames': + return p.tutorNames(); + case 'tutorial': + return p.shortTutorialDescription(); + case 'targetGrade': + return p.targetGrade ?? -1; + case 'submittedGrade': + return p.submittedGrade ?? -1; + case 'orderScale': + return p.orderScale ?? 0; + case 'hasPortfolio': + return (p.hasPortfolio || (p.portfolioStatus ?? 0) > 0) ? 1 : 0; + case 'grade': + return p.grade ?? -1; + } + }; + + return rows.sort((a, b) => { + const av = getSortValue(a); + const bv = getSortValue(b); + + if (typeof av === 'number' && typeof bv === 'number') { + return dir * (av - bv); + } + + return dir * String(av).localeCompare(String(bv)); + }); + } + + private repaginateFromSorted(): void { + const start = this.pageIndex * this.pageSize; + const end = start + this.pageSize; + this.dataSource.data = this.sortedRows.slice(start, end); + } + + public pageChanged(event: PageEvent): void { + this.pageIndex = event.pageIndex; + this.pageSize = event.pageSize; + this.repaginateFromSorted(); + } + + public toggleSort(order: typeof this.sortOrder): void { + if (this.sortOrder === order) { + this.sortReverse = !this.sortReverse; + } else { + this.sortOrder = order; + this.sortReverse = false; + } + this.applyFiltersAndSort(); + } + + public gradeAcronym(grade: number): string { + // -1..3 + return this.gradeService.gradeAcronyms[String(grade)] ?? 'G'; + } + + public gradeColor(grade: number): string { + return this.gradeService.gradeColors[String(grade)] ?? '#808080'; + } + + public taskSegmentColor(taskStatusKey: string): string { + return this.taskService.statusColors.get(taskStatusKey as any) ?? '#CCCCCC'; + } + + public downloadPortfolios(): void { + // Intentionally left out: legacy button was commented out. + } + + public saveGrade(): void { + if (!this.project) return; + const score = this.project.grade; + if (score == null) return; + if (!this.project.gradeRationale?.trim()) return; + + this.project.assignGrade(score, this.project.gradeRationale); + this.project.refreshBurndownChartData(); + } + + public assignGradeAndRefresh(score: number): void { + if (!this.project) return; + if (!this.project.gradeRationale?.trim()) return; + this.project.assignGrade(score, this.project.gradeRationale); + this.project.refreshBurndownChartData(); + } + + public get showNoStudentMessage(): boolean { + return this.activeTab !== 'selectStudent' && !this.selectedStudent; + } + + public get canShowProgressAndPortfolio(): boolean { + return !!this.project && !!this.selectedStudent; + } + + public get selectedStudentLabel(): string { + return this.selectedStudent?.student?.name ?? ''; + } + + public get showFilteredCountText(): boolean { + return this.totalItems > 0 && this.totalItems < this.totalEnrolledStudents; + } + + public onSearchChange(): void { + this.applyFiltersAndSort(); + } + + public onPortfolioFilterChange(value: 'withPortfolio' | 'allStudents'): void { + this.portfolioFilter = value; + this.applyFiltersAndSort(); + } + + public onStudentFilterChange(value: 'allStudents' | 'myStudents'): void { + this.studentFilter = value; + this.applyFiltersAndSort(); + } + + public onSubmittedGradeChange(value: number): void { + this.filterOptions.selectedGrade = value; + this.applyFiltersAndSort(); + } +} + diff --git a/src/app/units/states/portfolios/portfolios.tpl.html b/src/app/units/states/portfolios/portfolios.tpl.html index 8625891202..c287aa5da4 100644 --- a/src/app/units/states/portfolios/portfolios.tpl.html +++ b/src/app/units/states/portfolios/portfolios.tpl.html @@ -1,356 +1 @@ -
- - - {{tab.title}} - - - -
-
-
-

Mark Portfolios

- Assess student portfolios -
-
-
-
- - -
-
- - -
-
- -
-
- -
-
- - - -
- -
- -
-
- -

No portfolios found

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - Student - - - - - Name - - - - - Tutor - - - - - Tutorial - - - - - Target - - - - - Submitted as - - - - - Submission Date - - - - - Stats - - - - Portfolio? - - - - - Grade - - -
{{student.student.studentId || student.student.username}}{{student.student.name}}{{student.tutorNames()}}{{student.shortTutorialDescription()}} - - - - {{student.portfolioSubmissionDate | date: 'EEE d MMM y, h:mm a'}} - - - {{bar.value}}% - - - - {{student.hasPortfolio ? "Yes" : "No"}} - {{student.grade}} - -
-
- -
-
-
-
-

Portfolio Details

- Review portfolio and assign grade. -
-
-
- -

Select student to view portfolio and assign grade

-
-
-
-
-
-
-

Review Progress of {{selectedStudent.student.name}}

- Review the students progress through the unit's tasks. -
-
-
- -
-
- -
-
-
-

Review Staff Notes for {{selectedStudent.student.name}}

- View internal notes recorded by staff about the student. -
-
-
- -
-
- -
-
-
-

Review Portfolio of {{selectedStudent.student.name}}

- View or download portfolio for assessment. -
-
-
- -

No Portfolio Submitted

-
-
- - -
-
- -
-
-
-

Grade for {{selectedStudent.student.name}}

- Assign Grade for this work. -
-
-
-
- -
- -
-
{{results.name}}
-

- -

-
-
- -
-
-
-
-
+