diff --git a/app/api/staff_grant_extension_api.rb b/app/api/staff_grant_extension_api.rb new file mode 100644 index 0000000000..e69de29bb2 diff --git a/app/services/extension_service.rb b/app/services/extension_service.rb new file mode 100644 index 0000000000..e69de29bb2 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..7a7fa6245c --- /dev/null +++ b/src/app/admin/modals/grant-extension-form/grant-extension-form.component.html @@ -0,0 +1,67 @@ +

Grant Extension

+
+ + + + + + Students + + + {{ student.name }} + + + + 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..fc6517630e --- /dev/null +++ b/src/app/admin/modals/grant-extension-form/grant-extension-form.component.ts @@ -0,0 +1,106 @@ +import { Component, OnInit, Inject } 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, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { ExtensionService } from 'src/app/api/services/extension.service'; + +@Component({ + selector: 'f-grant-extension-form', + standalone: true, + imports: [ + ReactiveFormsModule, + CommonModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + MatSliderModule, + MatButtonModule, + MatDialogModule + ], + templateUrl: './grant-extension-form.component.html', +}) +export class GrantExtensionFormComponent implements OnInit { + grantExtensionForm!: FormGroup; + isSubmitting = false; + // List of test students to be displayed in the dropdown + students = [ + { id: 1, name: 'Joe M' }, + { id: 2, name: 'Sahiru W' }, + { id: 3, name: 'Samindi M' }, + { id: 4, name: 'Samantha W' }, + { id: 5, name: 'Samantha M' }, + { id: 6, name: 'Samantha S' }, + { id: 7, name: 'Samantha T' }, + { id: 8, name: 'Samantha U' }, + { id: 9, name: 'Samantha V' }, + { id: 10, name: 'Samantha W' }, + { id: 11, name: 'Samantha X' }, + { id: 12, name: 'Samantha Y' }, + { id: 13, name: 'Samantha Z' } + ]; + + constructor( + private fb: FormBuilder, + private dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: { unitId: number; taskDefinitionId: number }, + private snackBar: MatSnackBar, + private extensionService: ExtensionService + ) { + console.log('[GrantExtensionFormComponent] Constructor data:', data); + } + + ngOnInit(): void { + this.grantExtensionForm = this.fb.group({ + student_ids: [[], Validators.required], + extension: [1, [Validators.required, Validators.min(1)]], + reason: ['', Validators.required], + notes: [''], + }); + } + + submitForm(): void { + if (this.grantExtensionForm.invalid) { + this.grantExtensionForm.markAllAsTouched(); + return; + } + + this.isSubmitting = true; + + const { student_ids, extension, reason, notes } = this.grantExtensionForm.value; + + const payload = { + student_ids, + task_definition_id: this.data.taskDefinitionId, + weeks_requested: extension, + comment: reason, + notes + }; + + + console.log('Sending request to grant extension:', payload); + this.extensionService.grantExtension(this.data.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; + } + }); + } + + 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..516637d9ad 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,20 @@ export class TaskStatusCardComponent implements OnChanges, AfterViewInit { this.task.refresh(); }); } + + openGrantExtensionDialog(): void { + this.dialog.open(GrantExtensionFormComponent, { + width: '600px', + disableClose: true, + data: { + unitId: this.task.unit.id, + taskDefinitionId: this.task.definition.id + } + }); + } + + 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