diff --git a/src/app/admin/modals/grant-extension-form/grant-extension-dialog.component.ts b/src/app/admin/modals/grant-extension-form/grant-extension-dialog.component.ts new file mode 100644 index 0000000000..9e8ff15782 --- /dev/null +++ b/src/app/admin/modals/grant-extension-form/grant-extension-dialog.component.ts @@ -0,0 +1,27 @@ +import { Component } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { GrantExtensionFormComponent } from './grant-extension-form.component'; +import { MatDialogModule } from '@angular/material/dialog'; +import { CommonModule } from '@angular/common'; + +@Component({ + selector: 'f-grant-extension-dialog', + standalone: true, + imports: [MatDialogModule, CommonModule, GrantExtensionFormComponent], + template: ` +

Grant Extension

+ + + + + + + ` +}) +export class GrantExtensionDialogComponent { + constructor(private dialogRef: MatDialogRef) {} + + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/admin/modals/grant-extension-form/grant-extension-form.component.html b/src/app/admin/modals/grant-extension-form/grant-extension-form.component.html new file mode 100644 index 0000000000..a67d00446e --- /dev/null +++ b/src/app/admin/modals/grant-extension-form/grant-extension-form.component.html @@ -0,0 +1,100 @@ +

Grant Extension

+
+ + + +
+ + Search Students + + + + +
+ + Select All + +
+ + +
+
+ {{ student.name }} ({{ student.id }}) +
+
+ + + Please select at least one student. + +
+ + +
+ + + + + +
+ + + + Reason + + + Please provide a reason for the extension. + + + + + + Additional Notes (optional) + + +
+ + + + + + +
+ diff --git a/src/app/admin/modals/grant-extension-form/grant-extension-form.component.spec.ts b/src/app/admin/modals/grant-extension-form/grant-extension-form.component.spec.ts new file mode 100644 index 0000000000..e9fd4f29ba --- /dev/null +++ b/src/app/admin/modals/grant-extension-form/grant-extension-form.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { GrantExtensionFormComponent } from './grant-extension-form.component'; + +describe('GrantExtensionFormComponent', () => { + let component: GrantExtensionFormComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GrantExtensionFormComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(GrantExtensionFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/admin/modals/grant-extension-form/grant-extension-form.component.ts b/src/app/admin/modals/grant-extension-form/grant-extension-form.component.ts new file mode 100644 index 0000000000..4c08e824a8 --- /dev/null +++ b/src/app/admin/modals/grant-extension-form/grant-extension-form.component.ts @@ -0,0 +1,182 @@ +import { Component, OnInit } from '@angular/core'; +import { FormGroup, FormBuilder, Validators, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { MatSliderModule } from '@angular/material/slider'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ExtensionService } from 'src/app/api/services/extension.service'; +import { FormsModule } from '@angular/forms'; +import { MatCheckboxModule } from '@angular/material/checkbox'; + +@Component({ + selector: 'f-grant-extension-form', + standalone: true, + imports: [ + ReactiveFormsModule, + CommonModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + MatSliderModule, + MatButtonModule, + MatDialogModule, + FormsModule, + MatCheckboxModule + ], + templateUrl: './grant-extension-form.component.html' +}) +export class GrantExtensionFormComponent implements OnInit { + grantExtensionForm!: FormGroup; + isSubmitting = false; + searchQuery = ''; + selectedStudents: number[] = []; + showStudentList = false; + + // Temporary values will be replaced with dynamic context + unitId = 1; + taskDefinitionId = 25; + + // List of test students to choose from + students = [ + { id: 1, name: 'Joe M' }, + { id: 2, name: 'Sahiru W' }, + { id: 3, name: 'Samindi M' }, + { id: 4, name: 'Student 4' }, + { id: 5, name: 'Student 5' }, + { id: 6, name: 'Student 6' }, + { id: 7, name: 'Student 7' }, + { id: 8, name: 'Student 8' }, + { id: 9, name: 'Student 9' }, + { id: 10, name: 'Student 10' }, + { id: 11, name: 'Student 11' }, + { id: 12, name: 'Student 12' }, + { id: 13, name: 'Student 13' }, + { id: 14, name: 'Student 14' }, + { id: 15, name: 'Student 15' }, + { id: 16, name: 'Student 16' }, + { id: 17, name: 'Student 17' }, + { id: 18, name: 'Student 18' }, + { id: 19, name: 'Student 19' }, + { id: 20, name: 'Student 20' } + ]; + filteredStudents = this.students; + + constructor( + private fb: FormBuilder, + private dialogRef: MatDialogRef, + private snackBar: MatSnackBar, + private extensionService: ExtensionService + ) {} + + // Initialize the reactive form with validators for each field + ngOnInit(): void { + this.grantExtensionForm = this.fb.group({ + students: [[], Validators.required], + extension: [1, [Validators.required, Validators.min(1)]], + reason: ['', Validators.required], + notes: [''] + }); + } + + // Filters students based on search query + filterStudents(): void { + if (!this.searchQuery) { + this.filteredStudents = this.students; + } else { + const query = this.searchQuery.toLowerCase(); + this.filteredStudents = this.students.filter(student => + student.name.toLowerCase().includes(query) || + student.id.toString().includes(query) + ); + } + } + + // Toggles student selection state + toggleStudent(studentId: number): void { + const index = this.selectedStudents.indexOf(studentId); + if (index === -1) { + this.selectedStudents.push(studentId); + } else { + this.selectedStudents.splice(index, 1); + } + this.grantExtensionForm.patchValue({ students: this.selectedStudents }); + } + + // Handles keyboard navigation for student selection + handleStudentKeydown(event: KeyboardEvent, studentId: number): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.toggleStudent(studentId); + } + } + + // Toggles selection of all filtered students + toggleSelectAll(): void { + if (this.selectedStudents.length === this.filteredStudents.length) { + this.selectedStudents = []; + } else { + this.selectedStudents = this.filteredStudents.map(student => student.id); + } + this.grantExtensionForm.patchValue({ students: this.selectedStudents }); + } + + // Checks if a student is selected + isStudentSelected(studentId: number): boolean { + return this.selectedStudents.includes(studentId); + } + + // Safely handles blur for checkboxes + handleCheckboxBlur(): void { + // This avoids calling blur() directly on the checkbox reference + // which can cause errors when the reference isn't to a DOM element + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur(); + } + } + + // Handles form submission. + // Builds the payload and sends it to the backend via the ExtensionService. + // Displays a success or error message and closes the dialog on success. + submitForm(): void { + if (this.grantExtensionForm.invalid) { + this.grantExtensionForm.markAllAsTouched(); + return; + } + + this.isSubmitting = true; + + const { students, extension, reason, notes } = this.grantExtensionForm.value; + const unitId = 1; // temporary value + const payload = { + student_ids: students, + task_definition_id: 25, + weeks_requested: extension, + comment: reason, + notes: notes, + }; + + this.extensionService.grantExtension(unitId, payload).subscribe({ + next: () => { + this.snackBar.open('Extension granted successfully!', 'Close', { duration: 3000 }); + this.dialogRef.close(true); + }, + error: (error) => { + const errorMsg = error?.error?.message || 'An unexpected error occurred. Please try again.'; + this.snackBar.open(`Failed to grant extension: ${errorMsg}`, 'Close', { duration: 5000 }); + console.error('Grant Extension Error:', error); + }, + complete: () => { + this.isSubmitting = false; + } + }); + } + + // Closes the dialog without submitting the form + close(): void { + this.dialogRef.close(); + } +} diff --git a/src/app/api/services/authentication.service.ts b/src/app/api/services/authentication.service.ts index dfd99dfd74..9ed91d0973 100644 --- a/src/app/api/services/authentication.service.ts +++ b/src/app/api/services/authentication.service.ts @@ -136,7 +136,7 @@ export class AuthenticationService { remember: boolean; }, ): Observable { - return this.httpClient.post(this.AUTH_URL, userCredentials).pipe( + return this.httpClient.post(this.AUTH_URL, userCredentials, { withCredentials: true }).pipe( map((response: any) => { // Extract relevant data from response and construct user object to store in cache. const user: User = this.userService.cache.getOrCreate( diff --git a/src/app/api/services/extension.service.ts b/src/app/api/services/extension.service.ts new file mode 100644 index 0000000000..0e27251cef --- /dev/null +++ b/src/app/api/services/extension.service.ts @@ -0,0 +1,38 @@ +import { Injectable } from '@angular/core'; +import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { Observable } from 'rxjs'; +import { UserService } from 'src/app/api/models/doubtfire-model'; + +interface GrantExtensionPayload { + student_ids: number[]; + task_definition_id: number; + weeks_requested: number; + comment: string; + notes?: string; +} + +@Injectable({ + providedIn: 'root' +}) +export class ExtensionService { + constructor( + private http: HttpClient, + private userService: UserService + ) {} + + grantExtension(unitId: number, payload: GrantExtensionPayload): Observable { + const authToken = this.userService.currentUser?.authenticationToken ?? ''; + const username = this.userService.currentUser?.username ?? ''; + + const headers = new HttpHeaders({ + 'Auth-Token': authToken, + 'Username': username + }); + + return this.http.post( + `/api/units/${unitId}/staff-grant-extension`, + payload, + { headers } + ); + } +} 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 e210890833..e2ced39d4a 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 @@ -1,44 +1,40 @@ @if (triggers?.length > 0) { - - - - - -

{{ task?.statusLabel() }}

-
-
- @for (trigger of triggers; track trigger) { - - -
{{ trigger.label }}
-
- } -
-
- } @if (triggers?.length < 0) { - - - -
{{ task?.statusLabel() }}
-
-
+ + + + + +

{{ task?.statusLabel() }}

+
+
+ @for (trigger of triggers; track trigger) { + + + +
{{ trigger.label }}
+
+
+ } +
+
}

{{ task?.statusHelp().reason }} {{ task?.statusHelp().action }}

- @if ( task?.unit.currentUserIsStaff || task?.canApplyForExtension() || (task?.inSubmittedState() && - task?.requiresFileUpload()) ) { @if (task?.canApplyForExtension()) { - - } @if (task?.inSubmittedState() && task?.requiresFileUpload()) { - + } - - } -
+ @if (task?.inSubmittedState() && task?.requiresFileUpload()) { + + } + @if (currentUser?.role === 'Admin' || currentUser?.role === 'Convenor') { + + + } + 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 d33646b066..f42c4ff36d 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 @@ -5,6 +5,11 @@ import { Task } from 'src/app/api/models/task'; import { TaskStatusEnum } from 'src/app/api/models/task-status'; import { TaskService } from 'src/app/api/services/task.service'; import { ExtensionModalService } from 'src/app/common/modals/extension-modal/extension-modal.service'; +import { MatDialog } from '@angular/material/dialog'; +import { GrantExtensionFormComponent } from 'src/app/admin/modals/grant-extension-form/grant-extension-form.component'; +import { UserService } from 'src/app/api/services/user.service'; + + @Component({ selector: 'f-task-status-card', @@ -19,6 +24,8 @@ export class TaskStatusCardComponent implements OnChanges, AfterViewInit { private extensions: ExtensionModalService, private taskService: TaskService, private router: UIRouter, + private dialog: MatDialog, + private userService: UserService, ) {} @Input() task: Task; @@ -66,4 +73,16 @@ export class TaskStatusCardComponent implements OnChanges, AfterViewInit { this.task.refresh(); }); } + + openGrantExtensionDialog(): void { + this.dialog.open(GrantExtensionFormComponent, { + width: '600px', + disableClose: true, + }); + } + + get currentUser() { + return this.userService.currentUser; + } + } diff --git a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts index ac792b582e..b9230045f9 100644 --- a/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts +++ b/src/app/projects/states/dashboard/project-dashboard/project-dashboard.component.ts @@ -15,7 +15,7 @@ import {ProjectService} from 'src/app/api/services/project.service'; import {GlobalStateService} from '../../index/global-state.service'; import {UserService} from 'src/app/api/services/user.service'; import {Project, TaskDefinition} from 'src/app/api/models/doubtfire-model'; - +import { MatDialog } from '@angular/material/dialog'; @Component({ selector: 'f-project-dashboard', templateUrl: './project-dashboard.component.html', @@ -43,6 +43,7 @@ export class ProjectDashboardComponent implements OnInit { private currentUser: UserService, private projectService: ProjectService, private globalStateService: GlobalStateService, + private dialog: MatDialog, ) {} startedDragging(event: CdkDragStart, div: HTMLDivElement) { diff --git a/src/styles.scss b/src/styles.scss index 0df654e6ba..f8ab056beb 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -63,4 +63,3 @@ $main-view-max-height: calc((var(--vh, 1vh) * (100)) - 85px); } } } - diff --git a/test/api/staff_grant_extension_test.rb b/test/api/staff_grant_extension_test.rb new file mode 100644 index 0000000000..e69de29bb2