diff --git a/src/app/api/models/project.ts b/src/app/api/models/project.ts index 2afef9ffa2..6eed890270 100644 --- a/src/app/api/models/project.ts +++ b/src/app/api/models/project.ts @@ -43,7 +43,8 @@ export class Project extends Entity { public hasPortfolio: boolean; public portfolioStatus: number; - public portfolioFiles: { kind: string; name: string; idx: number }[]; + public portfolioFiles: {kind: string; name: string; idx: number}[]; + public escalationAttemptsRemaining: number; public taskStats: { key: TaskStatusEnum; diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index e2d85bf61b..7ef9d5e716 100644 --- a/src/app/api/models/task.ts +++ b/src/app/api/models/task.ts @@ -18,6 +18,8 @@ import { TestAttempt, TestAttemptService, ScormComment, + UnitRoleService, + UnitRole, } from './doubtfire-model'; import {Grade} from './grade'; import {LOCALE_ID} from '@angular/core'; @@ -27,6 +29,18 @@ import {gradeTaskModal, uploadSubmissionModal} from 'src/app/ajs-upgraded-provid import {AlertService} from 'src/app/common/services/alert.service'; import {MappingFunctions} from '../services/mapping-fn'; +export const FeedbackModerationAction = { + ShowMore: 'show_more', + ShowLess: 'show_less', + DismissOk: 'dismiss_ok', + DismissGood: 'dismiss_good', + Upheld: 'upheld', + Overturn: 'overturn', +} as const; + +export type FeedbackModerationActionType = + (typeof FeedbackModerationAction)[keyof typeof FeedbackModerationAction]; + export class Task extends Entity { id: number; @@ -44,6 +58,8 @@ export class Task extends Entity { numNewComments: number = 0; hasExtensions: boolean; + moderationType: 'random_sample' | 'escalation' | 'first_feedback'; + project: Project; definition: TaskDefinition; @@ -93,6 +109,16 @@ export class Task extends Entity { return this.commentCache.currentValues; } + public get tutor(): UnitRole { + const enrolments = this.project.tutorialEnrolmentsCache.currentValues.filter( + (t) => t.tutorialStream.name === this.definition.tutorialStream.name, + ); + if (enrolments.length === 1) { + const user = enrolments[0].tutor; + return this.unit.staff.find((ur) => ur.user.id === user.id); + } + } + public addComment(textString): void { AppInjector.get(TaskCommentService) .addComment(this, textString, 'text') @@ -982,4 +1008,39 @@ export class Task extends Entity { return false; } + + public moderateFeedback( + action: FeedbackModerationActionType, + applyToAll: boolean = false, + ): Observable { + const unitRoleService: UnitRoleService = AppInjector.get(UnitRoleService); + + const tutor = this.tutor; + if (!tutor) { + return; + } + + return unitRoleService.post( + { + id: tutor.id, + taskId: this.id, + }, + { + endpointFormat: '/unit_roles/:id:/moderation/:taskId:', + body: { + action, + apply_to_all: applyToAll, + }, + }, + ); + } + + public requestFeedbackReview(): Observable { + const httpClient: HttpClient = AppInjector.get(HttpClient); + const url = `${AppInjector.get(DoubtfireConstants).API_URL}/projects/${ + this.project.id + }/task_def_id/${this.definition.id}/feedback_review`; + + return httpClient.post(url, {}); + } } diff --git a/src/app/api/models/tutor-note.ts b/src/app/api/models/tutor-note.ts new file mode 100644 index 0000000000..148d5fa9eb --- /dev/null +++ b/src/app/api/models/tutor-note.ts @@ -0,0 +1,55 @@ +import {Entity} from 'ngx-entity-service'; +import {AppInjector} from 'src/app/app-injector'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {TutorNoteService} from '../services/tutor-note.service'; +import {Project, Task, TaskDefinition, UnitRole, User, UserService} from './doubtfire-model'; + +export class TutorNote extends Entity { + id: number; + + unitRole: UnitRole; + task?: Task; + taskDefinition?: TaskDefinition; + project?: Project; + user: User; + note: string; + replyTo?: TutorNote; + replyToId: number; + readByUnitRole: boolean; + + createdAt: Date; + updatedAt: Date; + + constructor(data?: UnitRole) { + super(); + if (data) { + this.unitRole = data; + } else { + console.error('Failed to get unit role'); + } + } + + public get noteIsForMe(): boolean { + const userService: UserService = AppInjector.get(UserService); + return this.unitRole.user.id === userService.currentUser.id; + } + + public get authorIsMe(): boolean { + const userService: UserService = AppInjector.get(UserService); + return this.user.id === userService.currentUser.id; + } + + public delete() { + const tutorNoteService: TutorNoteService = AppInjector.get(TutorNoteService); + tutorNoteService + .delete({unitRoleId: this.unitRole.id, id: this.id}, {cache: this.unitRole.tutorNotesCache}) + .subscribe({ + next: () => { + AppInjector.get(AlertService).error('Successfully deleted tutor note', 4000); + }, + error: (error) => { + AppInjector.get(AlertService).error(error?.message || error || 'Unknown error', 2000); + }, + }); + } +} diff --git a/src/app/api/models/unit-role.ts b/src/app/api/models/unit-role.ts index 63bf9852de..837d1d8eb6 100644 --- a/src/app/api/models/unit-role.ts +++ b/src/app/api/models/unit-role.ts @@ -1,17 +1,25 @@ -import { Entity, EntityMapping } from 'ngx-entity-service'; -import { User, Unit } from './doubtfire-model'; +import {Entity, EntityCache, EntityMapping} from 'ngx-entity-service'; +import {User, Unit} from './doubtfire-model'; +import {TutorNote} from './tutor-note'; /** * A unit role represents a academic teaching role within a unit. Linking a user * to their role within the unit. */ export class UnitRole extends Entity { - id: number; role: string; user: User; unit: Unit; observerOnly: boolean; + mentorId: number; + tutorNoteCount: number; + + public get mentor(): UnitRole { + return this.unit?.staff.find((ur) => ur.id === this.mentorId); + } + + public readonly tutorNotesCache: EntityCache = new EntityCache(); /** * The id for updated roles - but we need to move away from this to the role string... @@ -29,10 +37,12 @@ export class UnitRole extends Entity { return this.user.matches(text) || this.unit.matches(text); } - public override toJson(mappingData: EntityMapping, ignoreKeys?: string[]): object { + public override toJson( + mappingData: EntityMapping, + ignoreKeys?: string[], + ): object { return { unit_role: super.toJson(mappingData, ignoreKeys), - } + }; } - } diff --git a/src/app/api/services/project.service.ts b/src/app/api/services/project.service.ts index a897e9c1dd..f5573dafcb 100644 --- a/src/app/api/services/project.service.ts +++ b/src/app/api/services/project.service.ts @@ -207,6 +207,7 @@ export class ProjectService extends CachedEntityService { }); }, }, + 'escalationAttemptsRemaining', ); this.mapping.addJsonKey( diff --git a/src/app/api/services/task.service.ts b/src/app/api/services/task.service.ts index e8e51ace8b..3fd9dc0532 100644 --- a/src/app/api/services/task.service.ts +++ b/src/app/api/services/task.service.ts @@ -22,6 +22,7 @@ export class TaskService extends CachedEntityService { private readonly taskInboxEndpoint = '/units/:id:/tasks/inbox'; private readonly taskExplorerEndpoint = '/units/:id:/task_definitions/:task_def_id:/tasks'; + private readonly taskModerationEndpoint = '/units/:id:/tasks/moderation'; private readonly refreshTaskEndpoint = 'projects/:projectId:/refresh_tasks/:taskDefinitionId:'; constructor(httpClient: HttpClient) { @@ -97,6 +98,7 @@ export class TaskService extends CachedEntityService { }); }, }, + 'moderationType', ); this.mapping.addJsonKey('qualityPts', 'grade', 'includeInPortfolio', 'trigger'); @@ -155,6 +157,24 @@ export class TaskService extends CachedEntityService { ); } + public queryTasksForMentorModeration(unit: Unit): Observable { + const cache: EntityCache = new EntityCache(); + return this.query( + { + id: unit.id, + }, + { + endpointFormat: this.taskModerationEndpoint, + cache: cache, + constructorParams: unit, + }, + ).pipe( + tap((tasks: Task[]) => { + unit.incorporateTasks(tasks); + }), + ); + } + public refreshExtensionDetails(task: Task): void { const pathIds = { projectId: task.project.id, diff --git a/src/app/api/services/tutor-note.service.ts b/src/app/api/services/tutor-note.service.ts new file mode 100644 index 0000000000..a1c85cbb30 --- /dev/null +++ b/src/app/api/services/tutor-note.service.ts @@ -0,0 +1,174 @@ +import {HttpClient} from '@angular/common/http'; +import {Injectable} from '@angular/core'; +import {CachedEntityService, RequestOptions} from 'ngx-entity-service'; +import {Observable, tap} from 'rxjs'; +import {ProjectService, Task, UnitRole, UserService} from 'src/app/api/models/doubtfire-model'; +import API_URL from 'src/app/config/constants/apiUrl'; +import {TutorNote} from '../models/tutor-note'; + +@Injectable() +export class TutorNoteService extends CachedEntityService { + protected readonly endpointFormat = 'unit_roles/:unitRoleId:/tutor_notes/:id:'; + protected readonly markAsReadEndpointFormat = + 'unit_roles/:unitRoleId:/tutor_notes/:id:/mark_as_read'; + + constructor( + httpClient: HttpClient, + private userService: UserService, + private projectService: ProjectService, + ) { + super(httpClient, API_URL); + + this.mapping.addKeys( + 'id', + 'note', + 'createdAt', + 'updatedAt', + 'replyToId', + { + keys: ['user', 'user_id'], + toEntityFn: (data: object, _key: string, tutorNote: TutorNote) => { + const userRole = tutorNote.unitRole.unit.staff.find((s) => s.user.id === data['user_id']); + // If the user is not a staff in the unit it will be null + return userRole?.user; + }, + }, + { + keys: ['taskDefinition', 'task_definition_id'], + toEntityFn: (data: object, _key: string, tutorNote: TutorNote) => { + const taskDefinition = tutorNote.unitRole.unit.taskDefinitions.find( + (td) => td.id === data['task_definition_id'], + ); + return taskDefinition; + }, + }, + { + keys: ['project', 'project_id'], + toEntityFn: (data: object, key: string, tutorNote: TutorNote) => { + const project = tutorNote.unitRole.unit.students.find((p) => p.id === data['project_id']); + return project; + }, + }, + 'readByUnitRole', + ); + + this.mapping.addJsonKey('note', 'createdAt', 'updatedAt'); + } + + public createInstanceFrom(_json: object, other?: UnitRole): TutorNote { + return new TutorNote(other); + } + + public addNote( + unitRole: UnitRole, + text: string, + task?: Task, + originalNote?: TutorNote, + ): Observable { + const pathId = { + unitRoleId: unitRole.id, + }; + + const body: FormData = new FormData(); + if (originalNote) { + body.append('reply_to_id', originalNote?.id.toString()); + } + + if (task) { + body.append('task_id', task?.id.toString()); + } + + body.append('note', text); + + const opts: RequestOptions = {endpointFormat: this.endpointFormat}; + opts.cache = unitRole.tutorNotesCache; + opts.body = body; + opts.constructorParams = unitRole; + + return this.create(pathId, opts); + } + + public updateTutorNoteReplies(tutorNotes: readonly TutorNote[]) { + for (const note of tutorNotes) { + if (note.replyToId) { + const repliedTo = tutorNotes.find((n) => n.id === note.replyToId); + if (repliedTo) { + note.replyTo = repliedTo; + } else { + // Remove deleted replies + note.replyTo = null; + } + } + } + } + + public updateNote(unitRole: UnitRole, note: TutorNote, text: string): Observable { + const pathId = { + unitRoleId: unitRole.id, + id: note.id, + }; + + const body: FormData = new FormData(); + body.append('note', text); + + const opts: RequestOptions = {endpointFormat: this.endpointFormat}; + opts.cache = unitRole.tutorNotesCache; + opts.body = body; + opts.constructorParams = unitRole; + + return this.put(pathId, opts).pipe( + tap((_note: TutorNote) => { + note.note = text; + }), + ); + } + + public markAsRead(unitRole: UnitRole, note: TutorNote): Observable { + const pathId = { + unitRoleId: unitRole.id, + id: note.id, + }; + + const opts: RequestOptions = {endpointFormat: this.markAsReadEndpointFormat}; + opts.cache = unitRole.tutorNotesCache; + opts.constructorParams = unitRole; + + return this.put(pathId, opts).pipe( + tap((response: boolean) => { + if (response) { + note.readByUnitRole = true; + unitRole.tutorNoteCount--; + // unitRole.tutorNoteCount = unitRole.tutorNotesCache.currentValues.filter( + // (note) => !note.readByUnitRole, + // ).length; + } + }), + ); + } + + public loadTutorNotes(unitRole: UnitRole, useFetch: boolean = false): Observable { + const options: RequestOptions = { + endpointFormat: this.endpointFormat, + cache: unitRole.tutorNotesCache, + sourceCache: unitRole.tutorNotesCache, + cacheBehaviourOnGet: 'cacheQuery', + constructorParams: unitRole, + }; + + if (useFetch) { + return super.fetchAll( + { + unitRoleId: unitRole.id, + }, + options, + ); + } else { + return super.query( + { + unitRoleId: unitRole.id, + }, + options, + ); + } + } +} diff --git a/src/app/api/services/unit-role.service.ts b/src/app/api/services/unit-role.service.ts index 71cb194422..e4aca28602 100644 --- a/src/app/api/services/unit-role.service.ts +++ b/src/app/api/services/unit-role.service.ts @@ -63,9 +63,11 @@ export class UnitRoleService extends CachedEntityService { }, }, 'observerOnly', + 'mentorId', + 'tutorNoteCount', ); - this.mapping.addJsonKey('roleId', 'userId', 'unitId', 'role', 'observerOnly'); + this.mapping.addJsonKey('roleId', 'userId', 'unitId', 'role', 'observerOnly', 'mentorId'); } public createInstanceFrom(json: any, other?: any): UnitRole { diff --git a/src/app/common/filters/tasks-by-tutor.pipe.ts b/src/app/common/filters/tasks-by-tutor.pipe.ts new file mode 100644 index 0000000000..c95634d6a3 --- /dev/null +++ b/src/app/common/filters/tasks-by-tutor.pipe.ts @@ -0,0 +1,15 @@ +import {Pipe, PipeTransform} from '@angular/core'; +import {Task} from '../../api/models/doubtfire-model'; + +@Pipe({ + name: 'tasksByTutor', +}) +export class TasksByTutorPipe implements PipeTransform { + transform(tasks: Task[], unitRoleId?: number | string): Task[] { + if (!tasks) return tasks; + + if (!unitRoleId || unitRoleId === 'all') return tasks; + + return tasks.filter((task) => task.tutor?.id === unitRoleId); + } +} diff --git a/src/app/common/footer/footer.component.html b/src/app/common/footer/footer.component.html index a31bf9fac8..b1caed6d6d 100644 --- a/src/app/common/footer/footer.component.html +++ b/src/app/common/footer/footer.component.html @@ -13,114 +13,121 @@
+
+ +
+ - -
+ +
+ @if (selectedTask && selectedTask.suggestedTaskStatus) { + + } + @if (selectedTask?.definition?.assessInPortfolioOnly) { + + } @else { + + } + -
- - @if (selectedTask && selectedTask.suggestedTaskStatus) { - - } - @if (selectedTask?.definition?.assessInPortfolioOnly) { - - } @else { - - } - - + - +
+ +
+
-
- -
- + @if (viewType === 'moderation' && selectedTask) { + + + + } +
@if (selectedTask?.similaritiesDetected) { @@ -156,6 +163,20 @@ } + @if (canAccessTutorNotes(selectedTask)) { +
+ +
+ } +
@if (selectedTask.project.staffNoteCount > 0) {
diff --git a/src/app/common/footer/footer.component.ts b/src/app/common/footer/footer.component.ts index fa44839024..d0d6c9d9ee 100644 --- a/src/app/common/footer/footer.component.ts +++ b/src/app/common/footer/footer.component.ts @@ -1,10 +1,12 @@ -import {Component, ElementRef, HostListener, OnInit, ViewChild} from '@angular/core'; +import {Component, ElementRef, HostListener, Input, OnInit, ViewChild} from '@angular/core'; import {Observable} from 'rxjs'; import {Task} from 'src/app/api/models/task'; import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-task.service'; import {TaskService} from 'src/app/api/services/task.service'; import {FileDownloaderService} from '../file-downloader/file-downloader.service'; import {TaskAssessmentModalService} from '../modals/task-assessment-modal/task-assessment-modal.service'; +import {UnitRole} from 'src/app/api/models/unit-role'; +import {UserService} from 'src/app/api/services/user.service'; @Component({ selector: 'f-footer', @@ -17,8 +19,11 @@ export class FooterComponent implements OnInit { public taskService: TaskService, private fileDownloader: FileDownloaderService, private taskAssessmentModal: TaskAssessmentModalService, + private userService: UserService, ) {} + @Input() viewType: 'inbox' | 'explorer' | 'moderation'; + selectedTask$: Observable; selectedTask: Task; @@ -48,12 +53,42 @@ export class FooterComponent implements OnInit { (this.warningText?.nativeElement.getBoundingClientRect().width + totalPaddingOffset) / 2; } + public canAccessTutorNotes(task: Task): boolean { + const tutor = task.tutor; + if (!tutor) { + return false; + } + + const currentUser = this.userService.currentUser; + const currentUserRole = task.unit.staff.find((ur) => ur.user.id === currentUser.id); + + if (!currentUserRole) { + return false; + } + + // Ensure the unit is mapped correctly to access the mentor + tutor.unit = task.unit; + + const canAccess = + currentUserRole.role === 'Convenor' || + currentUserRole.role === 'Admin' || + (tutor.mentor && tutor.mentor.id === currentUserRole.id) || + tutor.id === currentUserRole.id; + + return canAccess; + } + + public viewTutorNotes() { + this.selectedTaskService.showTutorNotes(); + } + ngOnInit(): void { // watch for changes to the selected task this.selectedTask$ = this.selectedTaskService.selectedTask$; this.selectedTask$.subscribe((task) => { this.selectedTask = task; + // We need to timeout to give the DOM a chance to place the elements setTimeout(() => { this.findSimilaritiesButton(); diff --git a/src/app/common/header/header.component.html b/src/app/common/header/header.component.html index 96706776c9..7b494c2198 100644 --- a/src/app/common/header/header.component.html +++ b/src/app/common/header/header.component.html @@ -45,6 +45,14 @@ cloud_sync_outline } + @if (currentUnit && currentUnitRole && currentUnitRole.tutorNoteCount) { + + + } @if (currentUnit) { + diff --git a/src/app/common/header/task-dropdown/task-dropdown.component.ts b/src/app/common/header/task-dropdown/task-dropdown.component.ts index b67f220b03..cd733052ec 100644 --- a/src/app/common/header/task-dropdown/task-dropdown.component.ts +++ b/src/app/common/header/task-dropdown/task-dropdown.component.ts @@ -24,6 +24,7 @@ export class TaskDropdownComponent { 'Student List': 'Students', 'Student Portfolios': 'Portfolios', 'Task Explorer': 'Task Explorer', + 'Task Moderation': 'Task Moderation', 'Task Inbox': 'Inbox', 'Task Lists': 'Tasks', 'Tutorial List': 'Tutorials', diff --git a/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.html b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.html new file mode 100644 index 0000000000..ac97e33fe5 --- /dev/null +++ b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.html @@ -0,0 +1 @@ + diff --git a/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.scss b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.ts b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.ts new file mode 100644 index 0000000000..70cf2c78af --- /dev/null +++ b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.component.ts @@ -0,0 +1,22 @@ +import {Component, Inject, OnInit} from '@angular/core'; +import {MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {Task} from 'src/app/api/models/task'; +import {UnitRole} from 'src/app/api/models/unit-role'; +import {TutorNotesModalData} from './tutor-notes-modal.service'; + +@Component({ + selector: 'f-tutor-notes-modal', + templateUrl: './tutor-notes-modal.component.html', + styleUrl: './tutor-notes-modal.component.scss', +}) +export class TutorNotesModalComponent implements OnInit { + constructor(@Inject(MAT_DIALOG_DATA) public data: TutorNotesModalData) {} + + task?: Task; + unitRole?: UnitRole; + + ngOnInit() { + this.task = this.data.task; + this.unitRole = this.data.unitRole; + } +} diff --git a/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.service.ts b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.service.ts new file mode 100644 index 0000000000..152821d97c --- /dev/null +++ b/src/app/common/modals/tutor-notes-modal/tutor-notes-modal.service.ts @@ -0,0 +1,33 @@ +import {Injectable} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import {Task} from 'src/app/api/models/task'; +import {UnitRole} from 'src/app/api/models/unit-role'; +import {TutorNotesModalComponent} from './tutor-notes-modal.component'; + +export interface TutorNotesModalData { + task?: Task; + unitRole?: UnitRole; +} + +@Injectable({ + providedIn: 'root', +}) +export class TutorNotesModalService { + constructor(public dialog: MatDialog) {} + + public show(task?: Task, unitRole?: UnitRole) { + const _dialogRef = this.dialog.open( + TutorNotesModalComponent, + { + data: { + task, + unitRole, + }, + width: '100%', + height: '90vh', + maxWidth: '900px', + panelClass: 'overflow-y-auto', + }, + ); + } +} diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 16e6c15557..ba5b377a60 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 {MatChipsModule} from '@angular/material/chips'; +import {MatChipListbox, MatChipsModule} from '@angular/material/chips'; import {MatGridListModule} from '@angular/material/grid-list'; import {PdfViewerModule} from 'ng2-pdf-viewer'; import {UIRouterUpgradeModule} from '@uirouter/angular-hybrid'; @@ -313,6 +313,13 @@ import {TaskPlannerCardComponent} from './projects/states/dashboard/directives/p import {OverseerStepService} from './api/services/overseer-step.service'; import {TaskOverseerReportComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component'; import {OverseerStepResultService} from './api/services/overseer-step-result.service'; +import {TutorNotesComponent} from './projects/states/tutor-notes/tutor-notes.component'; +import {TutorNotesViewComponent} from './projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component'; +import {TutorNoteService} from './api/services/tutor-note.service'; +import {ModerationComponent} from './units/states/tasks/inbox/directives/moderation/moderation.component'; +import {TutorNotesModalComponent} from './common/modals/tutor-notes-modal/tutor-notes-modal.component'; +import {FeedbackAppealModalComponent} from './tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component'; +import {ConfirmModerationModalComponent} from './units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component'; // See https://stackoverflow.com/questions/55721254/how-to-change-mat-datepicker-date-format-to-dd-mm-yyyy-in-simplest-way/58189036#58189036 const MY_DATE_FORMAT = { @@ -502,6 +509,12 @@ const GANTT_CHART_CONFIG = { TaskPlannerCardComponent, TaskPlannerPrerequisitesModalComponent, TaskOverseerReportComponent, + TutorNotesComponent, + TutorNotesViewComponent, + ModerationComponent, + TutorNotesModalComponent, + FeedbackAppealModalComponent, + ConfirmModerationModalComponent, ], providers: [ // Services we provide @@ -596,6 +609,7 @@ const GANTT_CHART_CONFIG = { TaskPlannerPrerequisitesModalService, OverseerStepService, OverseerStepResultService, + TutorNoteService, ], imports: [ FlexLayoutModule, @@ -662,6 +676,7 @@ 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 1fb4868e3c..4a46b0ac8c 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -95,6 +95,7 @@ import 'build/src/app/units/states/tasks/tasks.js'; import 'build/src/app/units/states/tasks/viewer/directives/directives.js'; import 'build/src/app/units/states/tasks/viewer/viewer.js'; import 'build/src/app/units/states/tasks/definition/definition.js'; +import 'build/src/app/units/states/tasks/moderation/moderation.js'; import 'build/src/app/units/states/portfolios/portfolios.js'; import 'build/src/app/units/states/groups/groups.js'; import 'build/src/app/units/states/states.js'; @@ -238,6 +239,7 @@ import {ProjectPlanComponent} from './projects/states/plan/project-plan.componen import {TaskPlannerComponent} from './projects/states/plan/task-planner/task-planner.component'; import {TaskPlannerCardComponent} from './projects/states/dashboard/directives/progress-dashboard/task-planner-card/task-planner-card.component'; import {TaskOverseerReportComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-overseer-report/task-overseer-report.component'; +import {TutorNotesComponent} from './projects/states/tutor-notes/tutor-notes.component'; export const DoubtfireAngularJSModule = angular .module('doubtfire', [ @@ -605,3 +607,8 @@ DoubtfireAngularJSModule.directive( 'fTaskOverseerReport', downgradeComponent({component: TaskOverseerReportComponent}), ); + +DoubtfireAngularJSModule.directive( + 'fTutorNotes', + downgradeComponent({component: TutorNotesComponent}), +); diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html index 7463d4fb13..7b49a65daa 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.html @@ -41,25 +41,34 @@
{{ task?.statusLabel() }}
} - - + +
+ - @if (task?.canApplyForExtension()) { - - } - @if (task?.inSubmittedState() && task?.requiresFileUpload()) { - - } - + @if (task?.canApplyForExtension()) { + + } + @if (task?.inSubmittedState() && task?.requiresFileUpload()) { + + } +
+ +
+ +
+ + + +
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts index 70934d7d51..d08c1b1ffd 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-status-card/task-status-card.component.ts @@ -11,6 +11,7 @@ import {SubmissionTypeModalService} from 'src/app/tasks/modals/submission-type-m import {Project} from 'src/app/api/models/project'; import {UserService} from 'src/app/api/services/user.service'; +import {FeedbackAppealModalService} from 'src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.service'; @Component({ selector: 'f-task-status-card', templateUrl: './task-status-card.component.html', @@ -27,6 +28,7 @@ export class TaskStatusCardComponent implements OnChanges, AfterViewInit { private doubtfireConstants: DoubtfireConstants, private submissionTypeModalService: SubmissionTypeModalService, private userService: UserService, + private feedbackAppealService: FeedbackAppealModalService, ) {} @Input() task: Task; @@ -111,4 +113,8 @@ export class TaskStatusCardComponent implements OnChanges, AfterViewInit { this.task.refresh(); }); } + + openFeedbackAppealModal(): void { + this.feedbackAppealService.show(this.task); + } } diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html index ca9f850437..8d7141d892 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.html @@ -54,4 +54,4 @@
- + \ No newline at end of file diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.ts index 771b1ce53e..8c10c71c40 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.ts +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/task-submission-card/task-submission-card.component.ts @@ -81,4 +81,4 @@ export class TaskSubmissionCardComponent implements OnChanges, OnInit { downloadSubmissionFiles(): void { this.fileDownloader.downloadFile(this.urls.files, `${this.task.definition.abbreviation}.zip`); } -} +} \ No newline at end of file diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.html new file mode 100644 index 0000000000..71ea152187 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.html @@ -0,0 +1,20 @@ +
+
+ comment +

Tutor Notes for {{ unitRole?.user?.name }}

+
+ @if (task) { +

+ {{ task.definition.abbreviation }} {{ task.definition.name }} ({{ + task.project.student.name + }}) +

+ } +

+ Notes left here are for staff-only discussion about the feedback given to the student. Use this + space to question, clarify, or discuss the tutor’s feedback. These notes are visible to + {{ unitRole?.user?.name }} and other convenors. +

+ + +
diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.scss b/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.ts b/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.ts new file mode 100644 index 0000000000..33ba1dd5a4 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.ts @@ -0,0 +1,18 @@ +import {Component, Input, OnInit} from '@angular/core'; +import {UnitRole} from 'src/app/api/models/unit-role'; + +@Component({ + selector: 'f-tutor-notes-view', + templateUrl: './tutor-notes-view.component.html', + styleUrls: ['./tutor-notes-view.component.scss'], +}) +export class TutorNotesViewComponent implements OnInit { + @Input() task?; + @Input() unitRole: UnitRole; + + ngOnInit(): void { + if (this.task && !this.unitRole) { + this.unitRole = this.task.tutor; + } + } +} diff --git a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.html b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.html index fc861e5b34..931c569b6e 100644 --- a/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.html +++ b/src/app/projects/states/dashboard/directives/task-dashboard/task-dashboard.component.html @@ -24,6 +24,10 @@ + + + + + +
+ @if (!loadingTutorNotes) { + @for (note of filteredNotes; track note) { + @if (note.replyToId) { +
+
+ reply +
+ @if (note.replyTo) { + + Replying to {{ note.replyTo.user.preferredName }} + {{ note.replyTo.user.lastName }} ({{ note.replyTo.user.nickname }}) + + {{ note.replyTo.note }} + } @else { + Replying to: Deleted note + } +
+
+
+ } + +
+ @if (!note.readByUnitRole) { + @if (note.noteIsForMe) { + + } + } @else { +
Read by tutor
+ } + @if (note.authorIsMe) { + edit + } + reply + delete +
+ +
+ + + {{ note.user?.firstName }} {{ note.user?.lastName }} +
+ {{ note.createdAt | humanizedDate }} +
+
+ +
+ @if (note.taskDefinition) { + {{ note.taskDefinition?.abbreviation }} {{ note.taskDefinition.name }} + } + @if (note.project) { + {{ note.project?.student.name }} + } +
+ + + @if (editingNote && editingNote.id === note.id) { + + Update Note + +
+ + +
+
+ } @else { +
+ } +
+
+ } + } @else { + + } +
+ +
+ + All Tasks + @for (option of taskDefinitionFilters; track option) { + + {{ option }} + + } + + + @if (replyingToNote) { +
+
+ reply +
+ + Replying to {{ replyingToNote.user.firstName }} {{ replyingToNote.user.lastName }} ({{ + replyingToNote.user.nickname + }}) + + {{ replyingToNote.note }} +
+
+ close +
+ } + + + Tutor note + +
+ +
+
+
+
diff --git a/src/app/projects/states/tutor-notes/tutor-notes.component.scss b/src/app/projects/states/tutor-notes/tutor-notes.component.scss new file mode 100644 index 0000000000..a6d41ed2d2 --- /dev/null +++ b/src/app/projects/states/tutor-notes/tutor-notes.component.scss @@ -0,0 +1,27 @@ +.mat-icon { + color: #9696969d; + font-size: 20px; + width: 20px; + height: 20px; + cursor: pointer; + vertical-align: middle; + text-align: center; + margin-left: 0.3em; +} + +.mat-icon:hover { + color: black; +} + +@keyframes blueGlowFade { + 0% { + background-color: rgba(66, 133, 244, 0.6); + } + 100% { + background-color: transparent; + } +} + +.flash-highlight { + animation: blueGlowFade 1s ease-out; +} diff --git a/src/app/projects/states/tutor-notes/tutor-notes.component.ts b/src/app/projects/states/tutor-notes/tutor-notes.component.ts new file mode 100644 index 0000000000..6f83b31b3d --- /dev/null +++ b/src/app/projects/states/tutor-notes/tutor-notes.component.ts @@ -0,0 +1,215 @@ +import {Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; +import {Task, UnitRole, UserService} from 'src/app/api/models/doubtfire-model'; +import {TutorNote} from 'src/app/api/models/tutor-note'; +import {TutorNoteService} from 'src/app/api/services/tutor-note.service'; +import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@Component({ + selector: 'f-tutor-notes', + templateUrl: './tutor-notes.component.html', + styleUrl: './tutor-notes.component.scss', +}) +export class TutorNotesComponent implements OnInit { + @ViewChild('tutorNotesContainer') tutorNotesContainer!: ElementRef; + @ViewChild('tutorNoteEditor', {static: false}) tutorNoteEditor!: ElementRef; + + @Input() unitRole: UnitRole; + @Input() task: Task; + + loadingTutorNotes: boolean = true; + + noteText: string = ''; + + editingNote?: TutorNote; + editingNoteText?: string = ''; + + replyingToNote?: TutorNote; + + hoveredNoteId: number | null = null; + + constructor( + private userService: UserService, + private tutorNoteService: TutorNoteService, + private alertService: AlertService, + private confirmationModalService: ConfirmationModalService, + ) {} + ngOnInit(): void { + if (this.task && !this.unitRole) { + this.unitRole = this.task.tutor; + } + + this.loadingTutorNotes = true; + this.tutorNoteService.loadTutorNotes(this.unitRole).subscribe((notes) => { + this.loadingTutorNotes = false; + this.tutorNoteService.updateTutorNoteReplies(this.unitRole?.tutorNotesCache.currentValues); + this.scrollDown(); + }); + if (this.task) { + this.selectedTaskDefinitions.set(this.task.definition.abbreviation, true); + } + } + + scrollToComment(commentID: number) { + document.querySelector(`#comment-${commentID}`).scrollIntoView(); + } + + scrollDown() { + setTimeout(() => { + const el = this.tutorNotesContainer.nativeElement; + el.scrollTop = el.scrollHeight; + }, 50); + } + + public submitNote() { + const noteText = this.noteText.trim(); + if (noteText === '') { + return; + } + + this.noteText = ''; + + this.tutorNoteService + .addNote(this.unitRole, noteText, this.task, this.replyingToNote) + .subscribe({ + next: (_note) => { + this.alertService.success('Succesfully submitted note', 4000); + this.scrollDown(); + this.replyingToNote = null; + this.tutorNoteService.updateTutorNoteReplies( + this.unitRole?.tutorNotesCache.currentValues, + ); + }, + error: (error) => { + this.alertService.error(`Failed to create note: ${error}`, 4000); + this.noteText = noteText; + }, + }); + } + + public updateNote() { + const noteText = this.editingNoteText.trim(); + if (noteText === '' || !this.editingNote) { + return; + } + + this.tutorNoteService.updateNote(this.unitRole, this.editingNote, noteText).subscribe({ + next: (_note) => { + this.alertService.success('Succesfully updated note', 4000); + this.editingNote = null; + this.editingNoteText = ''; + }, + error: (error) => { + this.alertService.error(`Failed to update note: ${error}`, 4000); + }, + }); + } + + public markAsRead(note: TutorNote) { + this.tutorNoteService.markAsRead(this.unitRole, note).subscribe({ + next: (response) => { + if (response) { + this.alertService.success(`Marked note as read`, 3000); + } else { + this.alertService.error(`Failed to mark as read`, 6000); + } + }, + error: (error) => { + this.alertService.error(`Failed to mark as read: ${error}`, 6000); + }, + }); + } + + public deleteNote(note: TutorNote) { + this.confirmationModalService.show( + 'Delete note', + 'Are you sure want to delete this tutor note?', + () => { + note.delete(); + }, + ); + } + + public replyToNote(note: TutorNote) { + this.replyingToNote = note; + } + public cancelReplyingToNote() { + this.replyingToNote = null; + } + + public editNote(note: TutorNote) { + if (!note.authorIsMe) { + return; + } + + this.editingNote = note; + this.editingNoteText = note.note; + setTimeout(() => { + this.autoResizeTutorNoteEditor(); + this.tutorNoteEditor?.nativeElement.focus(); + }); + } + + public cancelEditingNote() { + this.editingNote = null; + this.editingNoteText = ''; + } + + public autoResizeTutorNoteEditor() { + const el = this.tutorNoteEditor.nativeElement; + el.style.height = 'auto'; + el.offsetHeight; + el.style.height = el.scrollHeight + 'px'; + } + + scrollToNote(note: TutorNote): void { + const el = document.getElementById(`note-${note.id}`); + if (el) { + el.scrollIntoView({behavior: 'smooth', block: 'center'}); + el.classList.add('flash-highlight'); + setTimeout(() => el.classList.remove('flash-highlight'), 1000); + } + } + + public selectedTaskDefinitions: Map = new Map(); + + public get filteredNotes() { + const selected = this.selectedTaskDefinitions; + const allSelected = selected.size === 0 || selected.get('all'); + + return ( + this.unitRole?.tutorNotesCache?.currentValues?.filter((note) => { + const abbr = note.taskDefinition?.abbreviation; + // if (!abbr) return false; // skip notes without taskDefinition + if (allSelected) return true; + return selected.get(abbr); + }) ?? [] + ); + } + + toggleSelection(option: string) { + if (this.selectedTaskDefinitions.get(option)) { + this.selectedTaskDefinitions.set(option, false); + } else { + this.selectedTaskDefinitions.set(option, true); + } + } + + public get taskDefinitionFilters() { + const abbrs = + this.unitRole.tutorNotesCache.currentValues + .map((note) => note.taskDefinition?.abbreviation) + .filter(Boolean) ?? []; + + // Remove duplicates + return Array.from(new Set(abbrs)); + } + + openProject(event: Event, note: TutorNote) { + event.stopPropagation(); + const link = document.createElement('a'); + link.href = `/projects/${note.project.id}/dashboard/${note.taskDefinition.abbreviation}?tutor=true`; + link.target = '_blank'; + link.click(); + } +} diff --git a/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.html b/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.html new file mode 100644 index 0000000000..131bc86541 --- /dev/null +++ b/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.html @@ -0,0 +1,46 @@ +

Request Feedback Review

+ +

+ If you believe the feedback or marking on this task does not accurately reflect your work, you + may request a review. This allows another tutor to reassess the feedback and determine whether + it should be confirmed or revised. +

+ +

+ You have + + {{ task.project.escalationAttemptsRemaining }} review request{{ + task.project.escalationAttemptsRemaining !== 1 ? 's' : '' + }} + + remaining for this unit. If the original feedback is revised as a result of this review, the + request will not be counted against your remaining total. +

+ + + Reason for review request + +
+ {{ reviewComment?.length || 0 }}/1000 +
+
+

Review decisions are final once resolved.

+ + + + + +
diff --git a/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.scss b/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.ts b/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.ts new file mode 100644 index 0000000000..d44c1853ca --- /dev/null +++ b/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.component.ts @@ -0,0 +1,57 @@ +import {Component, Inject, OnInit} from '@angular/core'; +import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog'; +import {Task} from 'src/app/api/models/task'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {FeedbackAppealModalData} from './feedback-appeal-modal.service'; +import {TaskService} from 'src/app/api/services/task.service'; + +@Component({ + selector: 'f-feedback-appeal-modal', + templateUrl: './feedback-appeal-modal.component.html', + styleUrl: './feedback-appeal-modal.component.scss', +}) +export class FeedbackAppealModalComponent implements OnInit { + task: Task; + + reviewComment: string; + submitting: boolean; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: FeedbackAppealModalData, + private alerts: AlertService, + private taskService: TaskService, + ) {} + + ngOnInit() { + this.task = this.data.task; + } + + submit(): void { + this.submitting = true; + this.task.requestFeedbackReview().subscribe({ + next: (_response) => { + this.alerts.success( + `Requested feedback review for ${this.task.definition.abbreviation} ${this.task.definition.name}`, + 3000, + ); + setTimeout(() => { + // Fetch the "Feedback Review Requested" comment + this.taskService.notifyStatusChange(this.task); + setTimeout(() => { + this.task.addComment(this.reviewComment); + }, 250); + this.dismissModal(); + }, 250); + }, + error: (error) => { + this.alerts.error(`An error occurred: ${error}`, 3000); + this.submitting = false; + }, + }); + } + + public dismissModal() { + this.dialogRef.close(); + } +} diff --git a/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.service.ts b/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.service.ts new file mode 100644 index 0000000000..622d3bf99f --- /dev/null +++ b/src/app/tasks/modals/feedback-appeal-modal/feedback-appeal-modal.service.ts @@ -0,0 +1,29 @@ +import {Injectable} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import {Task} from 'src/app/api/models/task'; +import {FeedbackAppealModalComponent} from './feedback-appeal-modal.component'; + +export interface FeedbackAppealModalData { + task: Task; +} + +@Injectable({ + providedIn: 'root', +}) +export class FeedbackAppealModalService { + constructor(public dialog: MatDialog) {} + + public show(task: Task) { + const _dialogRef = this.dialog.open( + FeedbackAppealModalComponent, + { + data: { + task: task, + }, + position: {top: '2.5%'}, + width: '100%', + maxWidth: '700px', + }, + ); + } +} diff --git a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html index 8cbc442d50..924ec54829 100644 --- a/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html +++ b/src/app/tasks/task-comments-viewer/task-comments-viewer.component.html @@ -102,6 +102,27 @@
+
+ + + comment + + + + {{ comment.text }} + + +
+
Unit Staff > + + + Mentor + + + (None) + @for (unitRole of unitStaff; track unitRole) { + + {{ unitRole.user.name }} + } + + + + Actions diff --git a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts index f161c384aa..bf55d7b45d 100644 --- a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts +++ b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.ts @@ -7,6 +7,7 @@ import {UnitRole} from 'src/app/api/models/unit-role'; import {MatTableDataSource} from '@angular/material/table'; import {MatButtonToggleChange} from '@angular/material/button-toggle'; import {ConfirmationModalService} from 'src/app/common/modals/confirmation-modal/confirmation-modal.service'; +import {MatSelectChange} from '@angular/material/select'; @Component({ selector: 'unit-staff-editor', @@ -22,7 +23,14 @@ export class UnitStaffEditorComponent implements OnInit { filteredStaff: User[] = []; // Filtered staff members searchTerm: string = ''; // Search term entered by the user - displayedColumns: string[] = ['name', 'role', 'main-convenor', 'observer-only', 'actions']; + displayedColumns: string[] = [ + 'name', + 'role', + 'main-convenor', + 'observer-only', + 'mentor', + 'actions', + ]; dataSource = new MatTableDataSource(); // Inject services here @@ -87,6 +95,20 @@ export class UnitStaffEditorComponent implements OnInit { }); } + selectMentor(unitRole: UnitRole, event: MatSelectChange) { + const previousValue = unitRole.mentorId; + unitRole.mentorId = event.value; + unitRole.roleId = unitRole.role === 'Tutor' ? 2 : 3; + + this.unitRoleService.update(unitRole).subscribe({ + next: () => this.alertService.success('Mentor updated', 2000), + error: (response) => { + // Revert changes on error + unitRole.mentorId = previousValue; + this.alertService.error(response, 6000); + }, + }); + } /** * Changes who the `Main Convenor` of the unit is. * diff --git a/src/app/units/states/tasks/definition/definition.coffee b/src/app/units/states/tasks/definition/definition.coffee index bd0bad8241..9dbdc249ea 100644 --- a/src/app/units/states/tasks/definition/definition.coffee +++ b/src/app/units/states/tasks/definition/definition.coffee @@ -22,6 +22,7 @@ angular.module('doubtfire.units.states.tasks.definition', [ .controller('TaskDefinitionStateCtrl', ($scope, newTaskService) -> $scope.taskData.source = newTaskService.queryTasksForTaskExplorer.bind(newTaskService) + $scope.viewType = 'explorer' $scope.taskData.taskDefMode = true $scope.showSearchOptions = true $scope.filters = { diff --git a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html new file mode 100644 index 0000000000..907c9793df --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.html @@ -0,0 +1,43 @@ +

+
+
+ @if (action === 'show_less') { + thumb_up + } @else if (action === 'show_more') { + flag + } @else if (action === 'dismiss_ok') { + check_circle + } @else if (action === 'dismiss_good') { + task_alt + } @else if (action === 'overturn') { + gavel + } @else if (action === 'upheld') { + verified + } +
+
+
{{ title }}
+ + Tutor: {{ task.tutor.user.name }} +
+
+

+ +

+ {{ description }} +

+ @if (showDismissAll) { +

+ + Apply this action to all {{ task.definition.abbreviation }} {{ task.definition.name }} tasks + from {{ task.tutor?.user.name ?? 'N/A' }}, currently waiting for moderation + +

+ } +
+ + + + diff --git a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.scss b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.scss new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.ts b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.ts new file mode 100644 index 0000000000..9b61056ad6 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.component.ts @@ -0,0 +1,48 @@ +import {Component, Inject, OnInit} from '@angular/core'; +import {ConfirmModerationModalData} from './confirm-moderation-modal.service'; +import {MatDialogRef, MAT_DIALOG_DATA} from '@angular/material/dialog'; +import {TaskService} from 'src/app/api/services/task.service'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {FeedbackModerationActionType} from 'src/app/api/models/task'; +import {Task} from 'src/app/api/models/task'; + +@Component({ + selector: 'f-confirm-moderation-modal', + templateUrl: './confirm-moderation-modal.component.html', + styleUrl: './confirm-moderation-modal.component.scss', +}) +export class ConfirmModerationModalComponent implements OnInit { + task: Task; + title: string; + description: string; + action: FeedbackModerationActionType; + showDismissAll: boolean; + callback: (applyToAll: boolean) => void; + + dismissAllTasks: boolean = false; + + constructor( + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: ConfirmModerationModalData, + private alerts: AlertService, + private taskService: TaskService, + ) {} + + ngOnInit() { + this.task = this.data.task; + this.title = this.data.title; + this.description = this.data.description; + this.action = this.data.action; + this.showDismissAll = this.data.showDismissAll; + this.callback = this.data.callback; + } + + public runCallback() { + this.callback(this.dismissAllTasks); + this.dismiss(); + } + + public dismiss() { + this.dialogRef.close(); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.service.ts b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.service.ts new file mode 100644 index 0000000000..bd271f321c --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/confirm-moderation-modal/confirm-moderation-modal.service.ts @@ -0,0 +1,47 @@ +import {Injectable} from '@angular/core'; +import {MatDialog} from '@angular/material/dialog'; +import {FeedbackModerationActionType} from 'src/app/api/models/task'; +import {ConfirmModerationModalComponent} from './confirm-moderation-modal.component'; +import {Task} from 'src/app/api/models/task'; + +export interface ConfirmModerationModalData { + task: Task; + title: string; + description: string; + action: FeedbackModerationActionType; + showDismissAll: boolean; + callback: (applyToAll: boolean) => void; +} + +@Injectable({ + providedIn: 'root', +}) +export class ConfirmModerationModalService { + constructor(public dialog: MatDialog) {} + + public show( + task: Task, + title: string, + description: string, + action: FeedbackModerationActionType, + showDismissAll: boolean, + callback: (applyToAll: boolean) => void, + ) { + const _dialogRef = this.dialog.open< + ConfirmModerationModalComponent, + ConfirmModerationModalData + >(ConfirmModerationModalComponent, { + data: { + task, + title, + description, + action, + showDismissAll, + callback, + }, + position: {top: '2.5%'}, + width: '100%', + maxWidth: '650px', + }); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html new file mode 100644 index 0000000000..efb5f80449 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html @@ -0,0 +1,64 @@ + + + @if (task.moderationType === 'random_sample' || task.moderationType === 'first_feedback') { + + + + } @else if (task.moderationType === 'escalation') { + + + + } + diff --git a/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.scss b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.scss new file mode 100644 index 0000000000..168ca35fb9 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.scss @@ -0,0 +1,27 @@ +.center-buttons { + text-align: center; + + .button { + // margin: 0 6px; + transform: scale(0.8); + border-radius: 100%; + } + + .large-button { + transform: scale(1.15); + background-color: white; + } + + .large-button:hover { + background-color: #f5f5f5; + } + + .extra-large-button { + padding-top: 7px; + transform: scale(1.25); + } + + .extra-large-button:hover { + filter: brightness(0.85); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.ts b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.ts new file mode 100644 index 0000000000..991cf71574 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.ts @@ -0,0 +1,118 @@ +import {Component, Input} from '@angular/core'; +import {FeedbackModerationActionType, Task} from 'src/app/api/models/task'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {ConfirmModerationModalService} from './confirm-moderation-modal/confirm-moderation-modal.service'; +import {Router} from '@angular/router'; + +@Component({ + selector: 'f-moderation', + templateUrl: './moderation.component.html', + styleUrl: './moderation.component.scss', +}) +export class ModerationComponent { + @Input() task: Task; + + public moderated: Map = new Map(); + + constructor( + private alertService: AlertService, + private confirmModerationModal: ConfirmModerationModalService, + private router: Router, + ) {} + + overturn() { + this.confirmModerationModal.show( + this.task, + 'Overturn', + `The original feedback will be revised and the task outcome updated. + Provide clear, constructive feedback explaining the changes made, and record any guidance for the tutor in the tutor notes section.`, + 'overturn', + false, + () => { + this.moderateTask('overturn'); + }, + ); + } + + upheld() { + this.confirmModerationModal.show( + this.task, + 'Upheld', + `After reviewing the task and feedback, you are satisfied that the original marking and comments are appropriate and align with the assessment criteria. + No changes will be made. + Ensure any concerns raised by the student have been considered and addressed where necessary.`, + 'upheld', + false, + () => { + this.moderateTask('upheld'); + }, + ); + } + + showMore() { + this.confirmModerationModal.show( + this.task, + 'Show more from this tutor', + `There were concerns with feedback and you would like to see more tasks from this tutor. You will review this task again if further feedback is provided.`, + 'show_more', + false, + () => { + this.moderateTask('show_more'); + }, + ); + } + + showLess() { + this.confirmModerationModal.show( + this.task, + 'Show less from this tutor', + `The feedback provided by the tutor is satisfactory and you would like to see less of their feedback for moderation. You won't see this task again.`, + 'show_less', + true, + (applyToAll) => { + this.moderateTask('show_less', applyToAll); + }, + ); + } + + dismiss() { + this.confirmModerationModal.show( + this.task, + 'Dismiss from moderation', + 'This task will be removed from moderation and will not get a follow up. This will not affect how many tasks from this tutor are selected for moderation.', + 'dismiss_ok', + true, + (applyToAll) => { + this.moderateTask('dismiss_ok', applyToAll); + }, + ); + } + + private moderateTask(action: FeedbackModerationActionType, applyToAll: boolean = false) { + this.task.moderateFeedback(action, applyToAll).subscribe({ + next: (_response) => { + if (applyToAll) { + this.alertService.success( + `Tasks moderated successfully. Refresh the list to view the updated queue.`, + 6000, + ); + } else { + this.alertService.success(`Task moderated successfully`, 2000); + } + + this.setModerated(this.task); + }, + error: (error) => { + this.alertService.error(`Failed to moderate task: ${error}`, 6000); + }, + }); + } + + private setModerated(task: Task) { + this.moderated.set(task, true); + } + + public isModerated(task: Task): boolean { + return this.moderated.get(task); + } +} diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html index 2a36a9704f..cbe2bdbff4 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.html @@ -153,6 +153,26 @@ {{ states[tutorialSort].icon }}
+
+ + Tutors + + @for (t of tutorFilter; track t) { + + {{ t.inboxDescription }} + + } + + + +
diff --git a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts index 8be2d3a77e..2534696fc9 100644 --- a/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts +++ b/src/app/units/states/tasks/inbox/directives/staff-task-list/staff-task-list.component.ts @@ -34,6 +34,7 @@ import {HotkeysService} from '@ngneat/hotkeys'; import {Router} from '@angular/router'; import {TaskDefinitionService} from 'src/app/api/services/task-definition.service'; import {SidekiqProgressModalService} from 'src/app/common/modals/sidekiq-progress-modal/sidekiq-progress-modal.service'; +import {TasksByTutorPipe} from 'src/app/common/filters/tasks-by-tutor.pipe'; @Component({ selector: 'df-staff-task-list', @@ -65,14 +66,17 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { forceStream: boolean; studentName: string; tutorialIdSelected: any; + unitRoleIdSelected: number | string; taskDefinitionIdSelected: number | TaskDefinition; }; @Input() showSearchOptions = true; @Input() isNarrow: boolean; + @Input() viewType: 'inbox' | 'explorer' | 'moderation'; + userHasTutorials: boolean; - filteredTasks: any[] = null; + filteredTasks: Task[] = null; studentFilter: { id: number | string; @@ -82,7 +86,12 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { tutorial?: Tutorial; }[] = null; - tasks: any[] = null; + tutorFilter: { + id: number | string; + inboxDescription: string; + }[] = null; + + tasks: Task[] = null; // hasJplagReport: boolean = false; @@ -94,6 +103,7 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { definedTasksPipe = new TasksOfTaskDefinitionPipe(); tasksInTutorialsPipe = new TasksInTutorialsPipe(); taskWithStudentNamePipe = new TasksForInboxSearchPipe(); + tasksByTutorPipe = new TasksByTutorPipe(); // Let's call having a source of tasksForDefinition plus having a task definition // auto-selected with the search options open task def mode -- i.e., the mode // for selecting tasks by task definitions @@ -181,6 +191,7 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { tutorialIdSelected: (this.unitRole.role === 'Tutor' || 'Convenor') && this.userHasTutorials ? 'mine' : 'all', tutorials: [], + unitRoleIdSelected: 'all', taskDefinitionIdSelected: null, taskDefinition: null, forceStream: true, @@ -209,6 +220,20 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { }), ]; + this.tutorFilter = [ + { + id: 'all', + inboxDescription: 'All Tutors', + }, + ...this.unit.staff + .slice() + .sort((a, b) => (a.user?.name ?? '').localeCompare(b.user?.name ?? '')) + .map((ur: UnitRole) => ({ + id: ur.id, + inboxDescription: ur.user?.name, + })), + ]; + this.tutorialIdChanged(false); this.setTaskDefFromTaskKey(this.taskData.taskKey); @@ -299,6 +324,14 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { this.filters.forceStream, ); } + + if (this.filters.unitRoleIdSelected) { + filteredTasks = this.tasksByTutorPipe.transform( + filteredTasks, + this.filters.unitRoleIdSelected, + ); + } + filteredTasks = this.taskWithStudentNamePipe.transform(filteredTasks, this.filters.studentName); this.filteredTasks = filteredTasks; @@ -324,6 +357,15 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { selectEl.focus(); } + unitRoleIdChanged(attemptRefreshData: boolean = true): void { + this.applyFilters(); + + const isExplorerView = this.isTaskDefMode; + if (attemptRefreshData && !this.fetchedAllTasks && !isExplorerView) { + this.refreshData(); + } + } + tutorialIdChanged(attemptRefreshData: boolean = true): void { const tutorialId = this.filters.tutorialIdSelected; @@ -333,11 +375,13 @@ export class StaffTaskListComponent implements OnInit, OnChanges, OnDestroy { if (tutorialId === 'mine') { this.filters.tutorials = this.unit.tutorialsForUserName(this.userService.currentUser.name); + this.filters.unitRoleIdSelected = 'all'; } else if (tutorialId === 'all') { // Ignore tutorials filter this.filters.tutorials = null; } else { this.filters.tutorials = [filterOption.tutorial]; + this.filters.unitRoleIdSelected = 'all'; } this.applyFilters(); diff --git a/src/app/units/states/tasks/inbox/inbox.coffee b/src/app/units/states/tasks/inbox/inbox.coffee index 9f04950ca0..77afab0740 100644 --- a/src/app/units/states/tasks/inbox/inbox.coffee +++ b/src/app/units/states/tasks/inbox/inbox.coffee @@ -20,5 +20,6 @@ angular.module('doubtfire.units.states.tasks.inbox', []) .controller('TaskInboxStateCtrl', ($scope, newTaskService) -> $scope.taskData.source = newTaskService.queryTasksForTaskInbox.bind(newTaskService) + $scope.viewType = 'inbox' $scope.taskData.taskDefMode = false ) diff --git a/src/app/units/states/tasks/inbox/inbox.component.html b/src/app/units/states/tasks/inbox/inbox.component.html index 84edcabe13..cdaee8d1bd 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.html +++ b/src/app/units/states/tasks/inbox/inbox.component.html @@ -2,14 +2,14 @@
@if (taskData) { - + }
@if (subs$ | async) { @@ -42,20 +42,20 @@
- +
@if (taskData) { - + }
diff --git a/src/app/units/states/tasks/inbox/inbox.component.ts b/src/app/units/states/tasks/inbox/inbox.component.ts index 7e73fcccb1..9f2bd164ab 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.ts +++ b/src/app/units/states/tasks/inbox/inbox.component.ts @@ -1,12 +1,5 @@ import {CdkDragEnd, CdkDragStart, CdkDragMove} from '@angular/cdk/drag-drop'; -import { - Component, - ElementRef, - Input, - OnDestroy, - OnInit, - ViewChild, -} from '@angular/core'; +import {Component, ElementRef, Input, OnDestroy, OnInit, ViewChild} from '@angular/core'; import {MediaObserver} from 'ng-flex-layout'; import {UIRouter} from '@uirouter/angular'; import {auditTime, merge, Observable, of, Subject, tap, withLatestFrom} from 'rxjs'; @@ -19,6 +12,8 @@ import {HotkeysService, HotkeysHelpComponent} from '@ngneat/hotkeys'; import {MatDialog} from '@angular/material/dialog'; import {UserService} from 'src/app/api/services/user.service'; import {DoubtfireConstants} from 'src/app/config/constants/doubtfire-constants'; +import {Tutorial} from 'src/app/api/models/doubtfire-model'; +import {TaskDefinition} from 'src/app/api/models/task-definition'; @Component({ selector: 'f-inbox', @@ -29,18 +24,28 @@ export class InboxComponent implements OnInit, OnDestroy { @Input() unit: Unit; @Input() unitRole: UnitRole; @Input() taskData: {selectedTask: Task; any}; - + @Input() filters: { + taskDefinition: TaskDefinition; + tutorials: Tutorial[]; + forceStream: boolean; + studentName: string; + tutorialIdSelected: any; + taskDefinitionIdSelected: number | TaskDefinition; + }; + @Input() showSearchOptions: boolean; @ViewChild('inboxpanel') inboxPanel: ElementRef; @ViewChild('commentspanel') commentspanel: ElementRef; + @Input() viewType: 'inbox' | 'explorer' | 'moderation'; + subs$: Observable; private inboxStartSize$ = new Subject(); private dragMove$ = new Subject<{event: CdkDragMove; div: HTMLDivElement}>(); private dragMoveAudited$; - protected filters; - protected showSearchOptions; + // protected filters; + // protected showSearchOptions; public taskSelected = false; diff --git a/src/app/units/states/tasks/inbox/inbox.tpl.html b/src/app/units/states/tasks/inbox/inbox.tpl.html index d77bb14cc2..9adb06b9ad 100644 --- a/src/app/units/states/tasks/inbox/inbox.tpl.html +++ b/src/app/units/states/tasks/inbox/inbox.tpl.html @@ -1 +1,10 @@ - + diff --git a/src/app/units/states/tasks/moderation/moderation.coffee b/src/app/units/states/tasks/moderation/moderation.coffee new file mode 100644 index 0000000000..4a483fd314 --- /dev/null +++ b/src/app/units/states/tasks/moderation/moderation.coffee @@ -0,0 +1,33 @@ +angular.module('doubtfire.units.states.tasks.moderation', [ +]) + +# +# Get tasks for mentor moderation +# +.config(($stateProvider) -> + $stateProvider.state 'units/tasks/moderation', { + parent: 'units/tasks' + url: '/moderation/{taskKey:any}' + # We can recycle the task inbox, switching the data source scope variable + templateUrl: "units/states/tasks/inbox/inbox.tpl.html" + controller: "TaskModerationStateCtrl" + params: + taskKey: dynamic: true + data: + task: "Task Moderation" + pageTitle: "_Home_" + roleWhitelist: ['Tutor', 'Convenor', 'Admin', 'Auditor'] + } +) + +.controller('TaskModerationStateCtrl', ($scope, newTaskService) -> + $scope.taskData.source = newTaskService.queryTasksForMentorModeration.bind(newTaskService) + $scope.viewType = 'moderation' + $scope.showSearchOptions = false + $scope.filters = { + tutorialIdSelected: 'all' + taskDefinition: null + taskDefinitionIdSelected: null + } + $scope.taskData.taskDefMode = false +) diff --git a/src/app/units/states/tasks/tasks.coffee b/src/app/units/states/tasks/tasks.coffee index 141e0364c6..c9fbef5cb4 100644 --- a/src/app/units/states/tasks/tasks.coffee +++ b/src/app/units/states/tasks/tasks.coffee @@ -1,6 +1,7 @@ angular.module('doubtfire.units.states.tasks', [ 'doubtfire.units.states.tasks.inbox' 'doubtfire.units.states.tasks.definition' + 'doubtfire.units.states.tasks.moderation' 'doubtfire.units.states.tasks.viewer' ])