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
+
+
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