From 5e91e820433bbef4e9f9c20d41eae884f8c42b99 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 7 Jan 2026 13:40:54 +1100 Subject: [PATCH 01/38] feat: init tutor notes and mentorship --- src/app/api/models/tutor-note.ts | 50 ++++++ src/app/api/models/unit-role.ts | 21 ++- src/app/api/services/tutor-note.service.ts | 123 +++++++++++++ src/app/api/services/unit-role.service.ts | 3 +- src/app/common/footer/footer.component.html | 14 ++ src/app/common/footer/footer.component.ts | 50 ++++++ src/app/doubtfire-angular.module.ts | 6 + src/app/doubtfire-angularjs.module.ts | 6 + .../tutor-notes-view.component.html | 8 + .../tutor-notes-view.component.scss | 0 .../tutor-notes-view.component.ts | 21 +++ .../task-dashboard.component.html | 4 + .../states/dashboard/selected-task.service.ts | 5 + .../tutor-notes/tutor-notes.component.html | 127 +++++++++++++ .../tutor-notes/tutor-notes.component.scss | 27 +++ .../tutor-notes/tutor-notes.component.ts | 167 ++++++++++++++++++ .../unit-staff-editor.component.html | 19 ++ .../unit-staff-editor.component.ts | 24 ++- 18 files changed, 667 insertions(+), 8 deletions(-) create mode 100644 src/app/api/models/tutor-note.ts create mode 100644 src/app/api/services/tutor-note.service.ts create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.html create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.scss create mode 100644 src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.ts create mode 100644 src/app/projects/states/tutor-notes/tutor-notes.component.html create mode 100644 src/app/projects/states/tutor-notes/tutor-notes.component.scss create mode 100644 src/app/projects/states/tutor-notes/tutor-notes.component.ts diff --git a/src/app/api/models/tutor-note.ts b/src/app/api/models/tutor-note.ts new file mode 100644 index 0000000000..d585c1f5f5 --- /dev/null +++ b/src/app/api/models/tutor-note.ts @@ -0,0 +1,50 @@ +import {Entity} from 'ngx-entity-service'; +import {Project, Unit, User, UserService, Task, UnitRole} from './doubtfire-model'; +import {AppInjector} from 'src/app/app-injector'; +import {AlertService} from 'src/app/common/services/alert.service'; +import {StaffNoteService} from '../services/staff-note.service'; +import {TutorNoteService} from '../services/tutor-note.service'; + +export class TutorNote extends Entity { + id: number; + + // project: Project; + unitRole: UnitRole; + task: Task; + user: User; + note: string; + replyTo?: TutorNote; + replyToId: number; + createdAt: Date; + updatedAt: Date; + + constructor(data?: UnitRole) { + super(); + if (data) { + this.unitRole = data; + } else { + console.error('Failed to get project'); + } + } + + 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: (response: object) => { + AppInjector.get(AlertService).error('Successfully deleted tutor note', 4000); + // this.project.staffNoteCount--; + // staffNoteService.updateStaffNoteReplies(this.project.staffNoteCache.currentValues); + }, + 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..982f4fd600 100644 --- a/src/app/api/models/unit-role.ts +++ b/src/app/api/models/unit-role.ts @@ -1,18 +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; + // mentor: UnitRole; + mentorId: number; + + public readonly tutorNotesCache: EntityCache = new EntityCache(); + public get mentor(): UnitRole { + return this.unit?.staff.find((ur) => ur.id === this.mentorId); + } /** * The id for updated roles - but we need to move away from this to the role string... * @deprecated @@ -29,10 +36,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/tutor-note.service.ts b/src/app/api/services/tutor-note.service.ts new file mode 100644 index 0000000000..39e08b98e3 --- /dev/null +++ b/src/app/api/services/tutor-note.service.ts @@ -0,0 +1,123 @@ +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, 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 { + // public readonly staffNoteAdded$: EventEmitter = new EventEmitter(); + + protected readonly endpointFormat = 'unit_roles/:unitRoleId:/tutor_notes/:id:'; + + 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']); + // const user = this.userService.cache.getOrCreate(data[key]?.id, userService, data[key]); + // If the user is not a staff in the unit it will be null + return userRole?.user; + }, + }); + + this.mapping.addJsonKey('note', 'createdAt', 'updatedAt'); + } + + public createInstanceFrom(json: object, other?: UnitRole): TutorNote { + return new TutorNote(other); + } + + public addNote(unitRole: UnitRole, text: string, originalNote: TutorNote): Observable { + const pathId = { + unitRoleId: unitRole.id, + }; + + const body: FormData = new FormData(); + if (originalNote) { + body.append('reply_to_id', originalNote?.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).pipe( + tap((note: TutorNote) => { + // this.staffNoteAdded$.emit(note); + }), + ); + } + + 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 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..00102dd37a 100644 --- a/src/app/api/services/unit-role.service.ts +++ b/src/app/api/services/unit-role.service.ts @@ -63,9 +63,10 @@ export class UnitRoleService extends CachedEntityService { }, }, 'observerOnly', + 'mentorId', ); - 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/footer/footer.component.html b/src/app/common/footer/footer.component.html index a31bf9fac8..033beedc92 100644 --- a/src/app/common/footer/footer.component.html +++ b/src/app/common/footer/footer.component.html @@ -156,6 +156,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..e058cedd68 100644 --- a/src/app/common/footer/footer.component.ts +++ b/src/app/common/footer/footer.component.ts @@ -5,6 +5,8 @@ import {SelectedTaskService} from 'src/app/projects/states/dashboard/selected-ta 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,6 +19,7 @@ export class FooterComponent implements OnInit { public taskService: TaskService, private fileDownloader: FileDownloaderService, private taskAssessmentModal: TaskAssessmentModalService, + private userService: UserService, ) {} selectedTask$: Observable; @@ -48,12 +51,59 @@ export class FooterComponent implements OnInit { (this.warningText?.nativeElement.getBoundingClientRect().width + totalPaddingOffset) / 2; } + public markingTutor(task: Task): UnitRole { + const enrolments = task.project.tutorialEnrolmentsCache.currentValues.filter( + (t) => t.tutorialStream.name === task.definition.tutorialStream.name, + ); + // TODO: is checking for just the one tutorial enrolment correct? should be.. + if (enrolments.length === 1) { + const user = enrolments[0].tutor; + return task.unit.staff.find((ur) => ur.user.id === user.id); + } + return null; + } + + public canAccessTutorNotes(task: Task): boolean { + // TODO: if the access is truthy because current user == marking tutor + // TODO: we should only reveal the icon if a note has been left by their mentor + + const tutor = this.markingTutor(task); + if (!tutor) { + console.log('tutor invalid!'); + 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() { + console.log(`viewing tutor notes for `, this.markingTutor(this.selectedTask)); + 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/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 33c8db369b..11220a1da3 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -312,6 +312,9 @@ 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'; // 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 = { @@ -500,6 +503,8 @@ const GANTT_CHART_CONFIG = { TaskPlannerCardComponent, TaskPlannerPrerequisitesModalComponent, TaskOverseerReportComponent, + TutorNotesComponent, + TutorNotesViewComponent, ], providers: [ // Services we provide @@ -594,6 +599,7 @@ const GANTT_CHART_CONFIG = { TaskPlannerPrerequisitesModalService, OverseerStepService, OverseerStepResultService, + TutorNoteService, ], imports: [ FlexLayoutModule, diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index 40e8930490..69f7180a11 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -237,6 +237,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', [ @@ -599,3 +600,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/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..2c8698b7b9 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.html @@ -0,0 +1,8 @@ +
+
+ comment +

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

+
+ + +
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..99c0ea1297 --- /dev/null +++ b/src/app/projects/states/dashboard/directives/task-dashboard/directives/tutor-notes-view/tutor-notes-view.component.ts @@ -0,0 +1,21 @@ +import {Component, Input} from '@angular/core'; + +@Component({ + selector: 'f-tutor-notes-view', + templateUrl: './tutor-notes-view.component.html', + styleUrls: ['./tutor-notes-view.component.scss'], +}) +export class TutorNotesViewComponent { + @Input() task?; + // @Input() project; + public get unitRole() { + const enrolments = this.task.project.tutorialEnrolmentsCache.currentValues.filter( + (t) => t.tutorialStream.name === this.task.definition.tutorialStream.name, + ); + // TODO: is checking for just the one tutorial enrolment correct? should be.. + if (enrolments.length === 1) { + const user = enrolments[0].tutor; + return this.task.unit.staff.find((ur) => ur.user.id === user.id); + } + } +} 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..7d9aa48482 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 (!loadingStaffNotes) { + @for (note of unitRole?.tutorNotesCache?.currentValues; 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.authorIsMe) { + edit + } + reply + delete +
+
+ + + {{ note.user?.firstName }} {{ note.user?.lastName }} +
+ {{ note.createdAt | humanizedDate }} +
+
+ + + @if (editingNote && editingNote.id === note.id) { + + Update Note + +
+ + +
+
+ } @else { +
+ } +
+
+ } + } @else { + + } +
+ +
+ @if (replyingToNote) { +
+
+ reply +
+ + Replying to {{ replyingToNote.user.firstName }} {{ replyingToNote.user.lastName }} ({{ + replyingToNote.user.nickname + }}) + + {{ replyingToNote.note }} +
+
+ close +
+ } + + + Staff 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..9b2b2c4eff --- /dev/null +++ b/src/app/projects/states/tutor-notes/tutor-notes.component.ts @@ -0,0 +1,167 @@ +import {Component, ElementRef, Input, OnInit, ViewChild} from '@angular/core'; +import {Task, 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('staffNotesContainer') staffNotesContainer!: ElementRef; + @ViewChild('staffNoteEditor', {static: false}) staffNoteEditor!: ElementRef; + + // @Input() unitRole: UnitRole; + @Input() task: Task; + // @Input() project: Project; + + // TODO: allow unitRole Input(), but if its nil, we need to set it during OnInit + // TODO: otherwise, if task is null and unitRole exists (When current users access their own tutor notes, here we can load tutor notes with a unitRole and no task) + + public get unitRole() { + const enrolments = this.task.project.tutorialEnrolmentsCache.currentValues.filter( + (t) => t.tutorialStream.name === this.task.definition.tutorialStream.name, + ); + // TODO: is checking for just the one tutorial enrolment correct? should be.. + if (enrolments.length === 1) { + const user = enrolments[0].tutor; + return this.task.unit.staff.find((ur) => ur.user.id === user.id); + } + } + + loadingStaffNotes: 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 { + this.loadingStaffNotes = true; + this.tutorNoteService.loadTutorNotes(this.unitRole).subscribe((notes) => { + this.loadingStaffNotes = false; + this.tutorNoteService.updateTutorNoteReplies(this.unitRole?.tutorNotesCache.currentValues); + this.scrollDown(); + }); + } + + scrollToComment(commentID: number) { + document.querySelector(`#comment-${commentID}`).scrollIntoView(); + } + + scrollDown() { + setTimeout(() => { + const el = this.staffNotesContainer.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.replyingToNote).subscribe({ + next: (note) => { + this.alertService.success('Succesfully submitted note', 4000); + this.scrollDown(); + // TODO: maybe we do export a tutorNoteCount? but then we dont know for which tasks they are for + // TODO: itll only be helpful in the header notifications icon... + // TODO: otherwise, we could just load all the notes for a mentors mentee when loading the audt page? + + // this.project.staffNoteCount++; + 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 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.autoResizeStaffNoteEditor(); + this.staffNoteEditor?.nativeElement.focus(); + }); + } + + public cancelEditingNote() { + this.editingNote = null; + this.editingNoteText = ''; + } + + public autoResizeStaffNoteEditor() { + const el = this.staffNoteEditor.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); + } + } +} diff --git a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html index ae0ca0114d..431b09dfc3 100644 --- a/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html +++ b/src/app/units/states/edit/directives/unit-staff-editor/unit-staff-editor.component.html @@ -70,6 +70,25 @@

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. * From a74742a7059276863259112abfc483a6811a6346 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Thu, 8 Jan 2026 13:16:07 +1100 Subject: [PATCH 02/38] feat: tutor moderation route --- src/app/api/services/task.service.ts | 19 +++++++++++ src/app/common/footer/footer.component.ts | 2 +- .../task-dropdown.component.html | 7 ++++ .../task-dropdown/task-dropdown.component.ts | 1 + src/app/doubtfire-angularjs.module.ts | 1 + .../states/tasks/inbox/inbox.component.html | 32 +++++++++---------- .../states/tasks/inbox/inbox.component.ts | 25 ++++++++------- .../units/states/tasks/inbox/inbox.tpl.html | 10 +++++- .../states/tasks/moderation/moderation.coffee | 31 ++++++++++++++++++ src/app/units/states/tasks/tasks.coffee | 1 + 10 files changed, 100 insertions(+), 29 deletions(-) create mode 100644 src/app/units/states/tasks/moderation/moderation.coffee diff --git a/src/app/api/services/task.service.ts b/src/app/api/services/task.service.ts index e8e51ace8b..54d92d49c1 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) { @@ -155,6 +156,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/common/footer/footer.component.ts b/src/app/common/footer/footer.component.ts index e058cedd68..f7c420eae4 100644 --- a/src/app/common/footer/footer.component.ts +++ b/src/app/common/footer/footer.component.ts @@ -53,7 +53,7 @@ export class FooterComponent implements OnInit { public markingTutor(task: Task): UnitRole { const enrolments = task.project.tutorialEnrolmentsCache.currentValues.filter( - (t) => t.tutorialStream.name === task.definition.tutorialStream.name, + (t) => t.tutorialStream.abbreviation === task.definition.tutorialStream.abbreviation, ); // TODO: is checking for just the one tutorial enrolment correct? should be.. if (enrolments.length === 1) { diff --git a/src/app/common/header/task-dropdown/task-dropdown.component.html b/src/app/common/header/task-dropdown/task-dropdown.component.html index cc8cec968f..451a3abc74 100644 --- a/src/app/common/header/task-dropdown/task-dropdown.component.html +++ b/src/app/common/header/task-dropdown/task-dropdown.component.html @@ -106,6 +106,13 @@ > Explorer + 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/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index 69f7180a11..abf7f6d5ab 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'; diff --git a/src/app/units/states/tasks/inbox/inbox.component.html b/src/app/units/states/tasks/inbox/inbox.component.html index 84edcabe13..328ca94ed2 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) { @@ -48,14 +48,14 @@
@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..4df47dc500 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,7 +24,15 @@ 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; @@ -39,8 +42,8 @@ export class InboxComponent implements OnInit, OnDestroy { 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..d1769779ea 100644 --- a/src/app/units/states/tasks/inbox/inbox.tpl.html +++ b/src/app/units/states/tasks/inbox/inbox.tpl.html @@ -1 +1,9 @@ - + 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..446df9a389 --- /dev/null +++ b/src/app/units/states/tasks/moderation/moderation.coffee @@ -0,0 +1,31 @@ +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.taskData.taskDefMode = false + $scope.showSearchOptions = false + $scope.filters = { + tutorialIdSelected: 'all' + } +) 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' ]) From 569c829d22c0b1b0a2802fd74c8ad23a64e603c3 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:54:49 +1100 Subject: [PATCH 03/38] feat: add moderator actions --- src/app/api/models/task.ts | 34 ++++++++++ src/app/doubtfire-angular.module.ts | 2 + .../states/tasks/definition/definition.coffee | 1 + .../moderation/moderation.component.html | 28 ++++++++ .../moderation/moderation.component.scss | 0 .../moderation/moderation.component.ts | 67 +++++++++++++++++++ src/app/units/states/tasks/inbox/inbox.coffee | 1 + .../states/tasks/inbox/inbox.component.html | 3 + .../states/tasks/inbox/inbox.component.ts | 2 + .../units/states/tasks/inbox/inbox.tpl.html | 3 +- .../states/tasks/moderation/moderation.coffee | 3 +- 11 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html create mode 100644 src/app/units/states/tasks/inbox/directives/moderation/moderation.component.scss create mode 100644 src/app/units/states/tasks/inbox/directives/moderation/moderation.component.ts diff --git a/src/app/api/models/task.ts b/src/app/api/models/task.ts index e2d85bf61b..e8f71eed5c 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'; @@ -93,6 +95,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 +994,26 @@ export class Task extends Entity { return false; } + + public moderateFeedback(score: -1 | 0 | 1): 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: { + score: score, + }, + }, + ); + } } diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index 11220a1da3..d456484b19 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -315,6 +315,7 @@ import {OverseerStepResultService} from './api/services/overseer-step-result.ser 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'; // 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 = { @@ -505,6 +506,7 @@ const GANTT_CHART_CONFIG = { TaskOverseerReportComponent, TutorNotesComponent, TutorNotesViewComponent, + ModerationComponent, ], providers: [ // Services we provide 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/moderation.component.html b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html new file mode 100644 index 0000000000..6d5f05b073 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html @@ -0,0 +1,28 @@ +
+ + + + +
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..e69de29bb2 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..7ed48aa8c1 --- /dev/null +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.ts @@ -0,0 +1,67 @@ +import {Component, Input} from '@angular/core'; +import {Task} from 'src/app/api/models/task'; +import {AlertService} from 'src/app/common/services/alert.service'; + +@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) {} + + showMore() { + console.log('bad!'); + + this.task.moderateFeedback(-1).subscribe({ + next: (response) => { + console.log(response); + this.alertService.success(`Task moderated`, 2000); + this.setModerated(this.task); + }, + error: (error) => { + this.alertService.error(`Failed to moderate task: ${error}`, 6000); + }, + }); + } + + showLess() { + console.log('good!'); + + this.task.moderateFeedback(1).subscribe({ + next: (response) => { + console.log(response); + this.alertService.success(`Task moderated`, 2000); + this.setModerated(this.task); + }, + error: (error) => { + this.alertService.error(`Failed to moderate task: ${error}`, 6000); + }, + }); + } + + dismiss() { + this.task.moderateFeedback(0).subscribe({ + next: (response) => { + console.log(response); + this.alertService.success(`Task moderated`, 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/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 328ca94ed2..f28f83e243 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.html +++ b/src/app/units/states/tasks/inbox/inbox.component.html @@ -26,6 +26,9 @@ }
+ @if (viewType === 'moderation' && taskData.selectedTask) { + + }
; private inboxStartSize$ = new Subject(); diff --git a/src/app/units/states/tasks/inbox/inbox.tpl.html b/src/app/units/states/tasks/inbox/inbox.tpl.html index d1769779ea..9adb06b9ad 100644 --- a/src/app/units/states/tasks/inbox/inbox.tpl.html +++ b/src/app/units/states/tasks/inbox/inbox.tpl.html @@ -5,5 +5,6 @@ [task]="task" [project]="project" [filters]="filters" - [showSearchOptions]="showSearchOptions" + [show-search-options]="showSearchOptions" + [view-type]="viewType" > diff --git a/src/app/units/states/tasks/moderation/moderation.coffee b/src/app/units/states/tasks/moderation/moderation.coffee index 446df9a389..cc61515008 100644 --- a/src/app/units/states/tasks/moderation/moderation.coffee +++ b/src/app/units/states/tasks/moderation/moderation.coffee @@ -22,8 +22,7 @@ angular.module('doubtfire.units.states.tasks.moderation', [ .controller('TaskModerationStateCtrl', ($scope, newTaskService) -> $scope.taskData.source = newTaskService.queryTasksForMentorModeration.bind(newTaskService) - - $scope.taskData.taskDefMode = false + $scope.viewType = 'moderation' $scope.showSearchOptions = false $scope.filters = { tutorialIdSelected: 'all' From 9f15954c7ce37f9aa65279c56b94c47d902a3c8b Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Mon, 12 Jan 2026 14:16:47 +1100 Subject: [PATCH 04/38] refactor: add confirmation modals --- .../moderation/moderation.component.html | 5 +- .../moderation/moderation.component.ts | 51 ++++++++++--------- 2 files changed, 31 insertions(+), 25 deletions(-) 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 index 6d5f05b073..11d955afeb 100644 --- a/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html @@ -1,3 +1,4 @@ +
Tutor: {{ task.tutor?.user.name }}
- -
+ +
+ @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) { diff --git a/src/app/common/footer/footer.component.ts b/src/app/common/footer/footer.component.ts index f7c420eae4..7c562b3d33 100644 --- a/src/app/common/footer/footer.component.ts +++ b/src/app/common/footer/footer.component.ts @@ -1,4 +1,4 @@ -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'; @@ -22,6 +22,8 @@ export class FooterComponent implements OnInit { private userService: UserService, ) {} + @Input() viewType: 'inbox' | 'explorer' | 'moderation'; + selectedTask$: Observable; selectedTask: Task; 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 index 2c8698b7b9..c9f9bf872f 100644 --- 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 @@ -3,6 +3,11 @@ comment

Tutor Notes for {{ unitRole?.user?.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/units/states/tasks/inbox/directives/moderation/moderation.component.html b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html index 11d955afeb..2260d2346a 100644 --- a/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html +++ b/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.html @@ -1,29 +1,36 @@ -
Tutor: {{ task.tutor?.user.name }}
-
- + + -
+ 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 index e69de29bb2..9c9f0165e1 100644 --- a/src/app/units/states/tasks/inbox/directives/moderation/moderation.component.scss +++ 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/inbox.component.html b/src/app/units/states/tasks/inbox/inbox.component.html index f28f83e243..cdaee8d1bd 100644 --- a/src/app/units/states/tasks/inbox/inbox.component.html +++ b/src/app/units/states/tasks/inbox/inbox.component.html @@ -26,9 +26,6 @@ }
- @if (viewType === 'moderation' && taskData.selectedTask) { - - }
- +
From 9843bbb333575e6f57913dc7ccac23564a100b91 Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Tue, 13 Jan 2026 13:32:36 +1100 Subject: [PATCH 06/38] feat: filter tutor notes by task --- src/app/api/models/tutor-note.ts | 6 +- src/app/api/services/tutor-note.service.ts | 52 +++++++++--- src/app/doubtfire-angular.module.ts | 3 +- .../tutor-notes-view.component.html | 9 ++- .../tutor-notes/tutor-notes.component.html | 35 +++++++- .../tutor-notes/tutor-notes.component.ts | 81 +++++++++++++++---- 6 files changed, 153 insertions(+), 33 deletions(-) diff --git a/src/app/api/models/tutor-note.ts b/src/app/api/models/tutor-note.ts index d585c1f5f5..3eb16acae3 100644 --- a/src/app/api/models/tutor-note.ts +++ b/src/app/api/models/tutor-note.ts @@ -1,5 +1,5 @@ import {Entity} from 'ngx-entity-service'; -import {Project, Unit, User, UserService, Task, UnitRole} from './doubtfire-model'; +import {Project, Unit, User, UserService, Task, UnitRole, TaskDefinition} from './doubtfire-model'; import {AppInjector} from 'src/app/app-injector'; import {AlertService} from 'src/app/common/services/alert.service'; import {StaffNoteService} from '../services/staff-note.service'; @@ -10,7 +10,9 @@ export class TutorNote extends Entity { // project: Project; unitRole: UnitRole; - task: Task; + task?: Task; + taskDefinition?: TaskDefinition; + project?: Project; user: User; note: string; replyTo?: TutorNote; diff --git a/src/app/api/services/tutor-note.service.ts b/src/app/api/services/tutor-note.service.ts index 39e08b98e3..a78db5dd01 100644 --- a/src/app/api/services/tutor-note.service.ts +++ b/src/app/api/services/tutor-note.service.ts @@ -2,7 +2,7 @@ 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, UnitRole, UserService} from 'src/app/api/models/doubtfire-model'; +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'; @@ -19,15 +19,38 @@ export class TutorNoteService extends CachedEntityService { ) { 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']); - // const user = this.userService.cache.getOrCreate(data[key]?.id, userService, data[key]); - // If the user is not a staff in the unit it will be null - return userRole?.user; + 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']); + // const user = this.userService.cache.getOrCreate(data[key]?.id, userService, data[key]); + // 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; + }, }, - }); + ); this.mapping.addJsonKey('note', 'createdAt', 'updatedAt'); } @@ -36,7 +59,12 @@ export class TutorNoteService extends CachedEntityService { return new TutorNote(other); } - public addNote(unitRole: UnitRole, text: string, originalNote: TutorNote): Observable { + public addNote( + unitRole: UnitRole, + text: string, + task?: Task, + originalNote?: TutorNote, + ): Observable { const pathId = { unitRoleId: unitRole.id, }; @@ -46,6 +74,10 @@ export class TutorNoteService extends CachedEntityService { 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}; diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index d456484b19..26841bbd77 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'; @@ -668,6 +668,7 @@ const GANTT_CHART_CONFIG = { NgxGanttModule, MatSidenavModule, MonacoEditorModule.forRoot(), + MatChipListbox, ], }) 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 index c9f9bf872f..fb240c9b45 100644 --- 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 @@ -1,8 +1,15 @@ -
+
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 diff --git a/src/app/projects/states/tutor-notes/tutor-notes.component.html b/src/app/projects/states/tutor-notes/tutor-notes.component.html index f3d87a9981..529581a4fe 100644 --- a/src/app/projects/states/tutor-notes/tutor-notes.component.html +++ b/src/app/projects/states/tutor-notes/tutor-notes.component.html @@ -6,7 +6,7 @@ } -->

@if (!loadingStaffNotes) { - @for (note of unitRole?.tutorNotesCache?.currentValues; track note) { + @for (note of filteredNotes; track note) { @if (note.replyToId) {
reply delete
+
@@ -59,6 +60,23 @@
+
+ @if (note.taskDefinition) { + {{ note.taskDefinition?.abbreviation }} {{ note.taskDefinition.name }} + } + @if (note.project) { + + {{ note.project?.student.name }} + } +
+ @if (editingNote && editingNote.id === note.id) { @@ -93,6 +111,19 @@
+ + All Tasks + @for (option of taskDefinitionFilters; track option) { + + {{ option }} + + } + + @if (replyingToNote) {
- Staff Note + Tutor note +
+ {{ reviewComment?.length || 0 }}/1000 +
+

Review decisions are final once resolved.

- + 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 index 3004560f7a..d44c1853ca 100644 --- 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 @@ -13,6 +13,9 @@ import {TaskService} from 'src/app/api/services/task.service'; export class FeedbackAppealModalComponent implements OnInit { task: Task; + reviewComment: string; + submitting: boolean; + constructor( public dialogRef: MatDialogRef, @Inject(MAT_DIALOG_DATA) public data: FeedbackAppealModalData, @@ -25,6 +28,7 @@ export class FeedbackAppealModalComponent implements OnInit { } submit(): void { + this.submitting = true; this.task.requestFeedbackReview().subscribe({ next: (_response) => { this.alerts.success( @@ -34,13 +38,17 @@ export class FeedbackAppealModalComponent implements OnInit { 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; }, }); - this.dismissModal(); } public dismissModal() { From 8cdc482e88fda437f8526c7ec30729d00e72b66f Mon Sep 17 00:00:00 2001 From: b0ink <40929320+b0ink@users.noreply.github.com> Date: Wed, 18 Feb 2026 13:45:29 +1100 Subject: [PATCH 38/38] chore: increase modal max width --- .../feedback-appeal-modal/feedback-appeal-modal.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d6726ac1b4..622d3bf99f 100644 --- 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 @@ -22,7 +22,7 @@ export class FeedbackAppealModalService { }, position: {top: '2.5%'}, width: '100%', - maxWidth: '650px', + maxWidth: '700px', }, ); }