diff --git a/backend/api/academics/hiring.py b/backend/api/academics/hiring.py
index 6f60a24d5..76802958f 100644
--- a/backend/api/academics/hiring.py
+++ b/backend/api/academics/hiring.py
@@ -163,6 +163,18 @@ def update_status(
return hiring_service.update_status(subject, course_site_id, hiring_status)
+@api.get("/{course_site_id}/enrollment", tags=["Hiring"])
+def get_course_site_total_enrollment(
+ course_site_id: int,
+ subject: User = Depends(registered_user),
+ hiring_service: HiringService = Depends(),
+) -> int:
+ """
+ Returns the total enrollment across all sections in a course site.
+ """
+ return hiring_service.get_course_site_total_enrollment(subject, course_site_id)
+
+
@api.get("/summary/{term_id}", tags=["Hiring"])
def get_hiring_summary_overview(
term_id: str,
diff --git a/backend/entities/academics/hiring/application_review_entity.py b/backend/entities/academics/hiring/application_review_entity.py
index 30dd3326c..35af525eb 100644
--- a/backend/entities/academics/hiring/application_review_entity.py
+++ b/backend/entities/academics/hiring/application_review_entity.py
@@ -58,6 +58,11 @@ class ApplicationReviewEntity(EntityBase):
preference: Mapped[int] = mapped_column(Integer)
# Notes
notes: Mapped[str] = mapped_column(String)
+ # Level
+ level_id: Mapped[int | None] = mapped_column(
+ ForeignKey("academics__hiring__level.id"), nullable=True
+ )
+ level: Mapped["HiringLevelEntity"] = relationship(back_populates="hiring_reviews")
@classmethod
def from_model(cls, model: ApplicationReview) -> Self:
@@ -76,6 +81,7 @@ def from_model(cls, model: ApplicationReview) -> Self:
status=model.status,
preference=model.preference,
notes=model.notes,
+ level_id=(model.level.id if model.level else None),
)
def to_overview_model(self) -> ApplicationReviewOverview:
@@ -133,6 +139,7 @@ def to_overview_model(self) -> ApplicationReviewOverview:
applicant_id=self.application.user_id,
applicant_course_ranking=applicant_preference_for_course
+ 1, # Increment since starting index is 0.
+ level=(self.level.to_model() if self.level else None),
)
def to_csv_row(self) -> ApplicationReviewCsvRow:
@@ -177,4 +184,5 @@ def to_csv_row(self) -> ApplicationReviewCsvRow:
status=self.status,
preference=self.preference,
notes=self.notes,
+ level=(self.level.title if self.level else None),
)
diff --git a/backend/entities/academics/hiring/hiring_level_entity.py b/backend/entities/academics/hiring/hiring_level_entity.py
index fc0d708b0..29e08c0ba 100644
--- a/backend/entities/academics/hiring/hiring_level_entity.py
+++ b/backend/entities/academics/hiring/hiring_level_entity.py
@@ -45,6 +45,12 @@ class HiringLevelEntity(EntityBase):
back_populates="hiring_level"
)
+ # Application reviews with this level
+ # NOTE: This defines a one-to-many relationship between the application reviews and level tables.
+ hiring_reviews: Mapped[list["ApplicationReviewEntity"]] = relationship(
+ back_populates="level"
+ )
+
@classmethod
def from_model(cls, model: HiringLevel) -> Self:
return cls(
diff --git a/backend/models/academics/hiring/application_review.py b/backend/models/academics/hiring/application_review.py
index 4fd704f8a..066b98f65 100644
--- a/backend/models/academics/hiring/application_review.py
+++ b/backend/models/academics/hiring/application_review.py
@@ -1,6 +1,8 @@
from pydantic import BaseModel
from enum import Enum
+from .hiring_level import HiringLevel
+
from ...application import Comp227, ApplicationUnderReview
from ...public_user import PublicUser
@@ -28,6 +30,7 @@ class ApplicationReview(BaseModel):
status: ApplicationReviewStatus = ApplicationReviewStatus.NOT_PROCESSED
preference: int
notes: str
+ level: HiringLevel | None = None
class ApplicationReviewOverview(ApplicationReview):
@@ -39,6 +42,7 @@ class ApplicationReviewOverview(ApplicationReview):
preference: int
notes: str
applicant_course_ranking: int
+ level: HiringLevel | None = None
class HiringStatus(BaseModel):
@@ -76,3 +80,4 @@ class ApplicationReviewCsvRow(BaseModel):
status: ApplicationReviewStatus = ApplicationReviewStatus.NOT_PROCESSED
preference: int
notes: str
+ level: str | None
diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py
index 15726edda..cd5ce16d7 100644
--- a/backend/services/academics/hiring.py
+++ b/backend/services/academics/hiring.py
@@ -109,7 +109,32 @@ def update_status(
subject, "hiring.get_status", f"course_site/{course_site_id}"
)
- # Step 2: Update the values for all reviews.
+ # Step 2: Assign default levels to preferred applications that don't have one.
+ preferred_reviews_without_level = [
+ review for review in hiring_status.preferred if review.level is None
+ ]
+ if preferred_reviews_without_level:
+ applications = {
+ app.id: app
+ for app in self._session.scalars(
+ select(ApplicationEntity).where(
+ ApplicationEntity.id.in_(
+ [
+ review.application_id
+ for review in preferred_reviews_without_level
+ ]
+ )
+ )
+ ).all()
+ }
+ for review in preferred_reviews_without_level:
+ application = applications.get(review.application_id)
+ if application:
+ default_level = self._get_default_level_for_application(application)
+ if default_level:
+ review.level = default_level
+
+ # Step 3: Update the values for all reviews.
# Retrieve all reviews, indexed by ID for efficient searching.
hiring_status_reviews_by_id: dict[int, ApplicationReviewOverview] = {}
@@ -126,10 +151,17 @@ def update_status(
updates: list[dict] = []
for persisted in site_entity.application_reviews:
request = hiring_status_reviews_by_id[persisted.id]
+ requested_level_id = request.level.id if request.level else None
if (
- persisted.status != request.status
- or persisted.preference != request.preference
- or persisted.notes != request.notes
+ persisted.status,
+ persisted.preference,
+ persisted.notes,
+ persisted.level_id,
+ ) != (
+ request.status,
+ request.preference,
+ request.notes,
+ requested_level_id,
):
updates.append(
{
@@ -137,6 +169,7 @@ def update_status(
"status": request.status,
"preference": request.preference,
"notes": request.notes,
+ "level_id": requested_level_id,
}
)
@@ -148,6 +181,49 @@ def update_status(
# Reload the data and return the hiring status.
return self.get_status(subject, course_site_id)
+ def _get_default_level_for_application(
+ self, application: ApplicationEntity
+ ) -> HiringLevel | None:
+ """
+ Gets the default level for an application based on its type.
+ Finds the level that matches the classification, has load of 1.0, and is active.
+
+ Args:
+ application: The application entity to get default level for
+
+ Returns:
+ The default level model, or None if not found
+ """
+ # Determine classification based on application type
+ classification: HiringLevelClassification | None = None
+
+ if application.type == "new_uta":
+ classification = HiringLevelClassification.UG
+ elif application.type == "gta":
+ if application.program_pursued:
+ # Check if it's a PhD program
+ if application.program_pursued in ("PhD", "PhD (ABD)"):
+ classification = HiringLevelClassification.PHD
+ # Check if it's MS or BS/MS
+ elif application.program_pursued in ("MS", "BS/MS"):
+ classification = HiringLevelClassification.MS
+
+ if not classification:
+ return None
+
+ # Find the level that matches classification, has load 1.0, and is active
+ level_query = (
+ select(HiringLevelEntity)
+ .where(HiringLevelEntity.classification == classification)
+ .where(HiringLevelEntity.load == 1.0)
+ .where(HiringLevelEntity.is_active == True)
+ )
+ level_entity = self._session.scalar(level_query)
+
+ if level_entity:
+ return level_entity.to_model()
+ return None
+
def create_missing_course_sites_for_term(self, subject: User, term_id: str) -> bool:
"""
Creates missing course sites for a given term.
@@ -485,6 +561,7 @@ def _to_review_models(
status=review.status,
preference=review.preference,
notes=review.notes,
+ level=(review.level.to_model() if review.level else None),
applicant_course_ranking=applicant_preferences.get(
review.application_id, 999
),
@@ -665,7 +742,8 @@ def get_hiring_admin_course_overview(
.options(
joinedload(ApplicationReviewEntity.application).joinedload(
ApplicationEntity.user
- )
+ ),
+ joinedload(ApplicationReviewEntity.level),
)
)
preferred_review_entities = self._session.scalars(preferred_review_query).all()
@@ -681,6 +759,7 @@ def to_overview(review: ApplicationReviewEntity) -> ApplicationReviewOverview:
application=review.application.to_review_overview_model(),
applicant_id=review.application.user_id,
applicant_course_ranking=0,
+ level=(review.level.to_model() if review.level else None),
)
reviews = [
@@ -979,6 +1058,26 @@ def get_hiring_assignments_for_course_site(
params=pagination_params,
)
+ def get_course_site_total_enrollment(
+ self, subject: User, course_site_id: int
+ ) -> int:
+ """
+ Returns the sum of enrolled students across all sections in a course site.
+ """
+ site_entity = self._load_course_site(course_site_id)
+ if not self._is_instructor(subject, site_entity):
+ self._permission.enforce(
+ subject, "hiring.get_status", f"course_site/{course_site_id}"
+ )
+
+ sections_query = select(SectionEntity).where(
+ SectionEntity.course_site_id == course_site_id
+ )
+ total_enrollment = 0
+ for section in self._session.scalars(sections_query).all():
+ total_enrollment += section.enrolled
+ return total_enrollment
+
def get_assignment_summary_for_instructors_csv(
self, subject: User, course_site_id: int
) -> list[HiringAssignmentSummaryCsvRow]:
diff --git a/backend/test/services/academics/hiring/hiring_test.py b/backend/test/services/academics/hiring/hiring_test.py
index ff8f45e4a..60e27be23 100644
--- a/backend/test/services/academics/hiring/hiring_test.py
+++ b/backend/test/services/academics/hiring/hiring_test.py
@@ -19,6 +19,12 @@
from .....services.academics import HiringService
from .....services.application import ApplicationService
from .....services.academics.course_site import CourseSiteService
+from .....entities.academics.section_entity import SectionEntity
+from .....entities.academics.hiring.application_review_entity import (
+ ApplicationReviewEntity,
+)
+from sqlalchemy import select
+from sqlalchemy.orm import Session
# Injected Service Fixtures
from .fixtures import hiring_svc
@@ -98,6 +104,7 @@ def test_update_status(hiring_svc: HiringService):
status.not_preferred[0].status = ApplicationReviewStatus.PREFERRED
status.not_preferred[0].preference = 1
status.preferred[0].notes = "Updated notes!"
+ status.preferred[0].level = hiring_data.uta_level
status.not_processed[0].preference = 1
status.not_processed[1].preference = 0
@@ -109,6 +116,7 @@ def test_update_status(hiring_svc: HiringService):
assert len(new_status.preferred) == 2
assert new_status.preferred[0].application_id == hiring_data.application_two.id
assert new_status.preferred[0].notes == "Updated notes!"
+ assert new_status.preferred[0].level.id == hiring_data.uta_level.id
assert new_status.preferred[1].application_id == hiring_data.application_one.id
assert new_status.not_processed[0].application_id == hiring_data.application_four.id
assert (
@@ -303,3 +311,29 @@ def test_get_phd_applicants(hiring_svc: HiringService):
assert len(applicants) > 0
for applicant in applicants:
assert applicant.program_pursued in {"PhD", "PhD (ABD)"}
+
+
+def test_get_course_site_total_enrollment(hiring_svc: HiringService, session: Session):
+ """Verify total enrollment sums section enrollments for a course site."""
+ from ...office_hours import office_hours_data
+
+ course_site_id = office_hours_data.comp_110_site.id
+ total = hiring_svc.get_course_site_total_enrollment(
+ user_data.instructor, course_site_id
+ )
+ sections = session.scalars(
+ select(SectionEntity).where(SectionEntity.course_site_id == course_site_id)
+ ).all()
+ expected = sum(s.enrolled for s in sections)
+ assert total == expected
+
+
+def test_get_course_site_total_enrollment_checks_permission(hiring_svc: HiringService):
+ """Ambassador should not be able to read instructor-only enrollment."""
+ from ...office_hours import office_hours_data
+
+ with pytest.raises(UserPermissionException):
+ hiring_svc.get_course_site_total_enrollment(
+ user_data.ambassador, office_hours_data.comp_110_site.id
+ )
+ pytest.fail()
diff --git a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html
index b1c16a23e..5800171ac 100644
--- a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html
+++ b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.html
@@ -99,10 +99,14 @@
@if(data.viewOnly) {
-
-
Instructor's Notes
+
{{ data.review.notes }}
+
+
{{ data.review.level ? data.review.level.title : 'None' }}
} @else {
Your Notes
@@ -115,5 +119,18 @@
[formControl]="notes"
name="notes">
+
+
+
Preferred Hiring Level
+
+
+
+ Select Preferred Level
+
+ @for(level of hiringService.activeHiringlevels(); track level.id) {
+ {{ level.title }}
+ }
+
+
}
diff --git a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts
index e96d52754..2dd7875ee 100644
--- a/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts
+++ b/frontend/src/app/hiring/dialogs/application-dialog/application-dialog.dialog.ts
@@ -11,6 +11,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import {
ApplicationReviewOverview,
ApplicationReviewStatus,
+ HiringLevel,
HiringStatus
} from '../../hiring.models';
import { FormControl } from '@angular/forms';
@@ -33,6 +34,8 @@ export interface ApplicationDialogData {
export class ApplicationDialog implements OnInit, OnDestroy {
notes = new FormControl('');
notesSubcription!: Subscription;
+ preferredLevel = new FormControl(undefined);
+ preferredLevelSubscription!: Subscription;
constructor(
protected hiringService: HiringService,
@@ -40,6 +43,7 @@ export class ApplicationDialog implements OnInit, OnDestroy {
@Inject(MAT_DIALOG_DATA) public data: ApplicationDialogData
) {
this.notes.setValue(data.review.notes);
+ this.preferredLevel.setValue(data.review.level);
}
/** Save the notes data as the user types, with a debounce of 200ms. */
@@ -50,14 +54,24 @@ export class ApplicationDialog implements OnInit, OnDestroy {
.subscribe((_) => {
this.saveData();
});
+ this.preferredLevel.setValue(this.data.review.level);
+ this.preferredLevelSubscription = this.preferredLevel.valueChanges
+ .subscribe((_) => {
+ this.saveData();
+ });
}
}
/** Unsubsribe from the notes subscription when the page is closed. */
ngOnDestroy(): void {
- this.notesSubcription.unsubscribe();
+ this.notesSubcription?.unsubscribe();
+ this.preferredLevelSubscription?.unsubscribe();
}
+ /** Compare hiring levels by their ID. */
+ compareLevels = (a?: HiringLevel | null, b?: HiringLevel | null) =>
+ (a?.id ?? null) === (b?.id ?? null);
+
youtubeVideoId(): string | undefined {
let splitUrl = this.data.review.application.intro_video_url?.split('?v=');
return splitUrl?.length ?? 0 > 0 ? splitUrl![1] : undefined;
@@ -68,14 +82,20 @@ export class ApplicationDialog implements OnInit, OnDestroy {
if (this.data.review.status == ApplicationReviewStatus.NOT_PREFERRED) {
this.data.status!.not_preferred[this.data.review.preference].notes =
this.notes.value ?? '';
+ this.data.status!.not_preferred[this.data.review.preference].level =
+ this.preferredLevel.value ?? null;
}
if (this.data.review.status == ApplicationReviewStatus.NOT_PROCESSED) {
this.data.status!.not_processed[this.data.review.preference].notes =
this.notes.value ?? '';
+ this.data.status!.not_processed[this.data.review.preference].level =
+ this.preferredLevel.value ?? null;
}
if (this.data.review.status == ApplicationReviewStatus.PREFERRED) {
this.data.status!.preferred[this.data.review.preference].notes =
this.notes.value ?? '';
+ this.data.status!.preferred[this.data.review.preference].level =
+ this.preferredLevel.value ?? null;
}
// Persist the data
diff --git a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.css b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.css
index 1773a8244..22e8ce819 100644
--- a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.css
+++ b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.css
@@ -2,6 +2,7 @@ mat-form-field {
width: 100%;
margin-top: 10px;
}
+
::ng-deep .user-chips {
margin-bottom: 4px !important;
}
@@ -13,4 +14,12 @@ mat-form-field {
width: 100%;
justify-content: space-between;
margin-bottom: 16px;
+}
+
+.option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ gap: 8px;
}
\ No newline at end of file
diff --git a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html
index 414f27c62..90b281bdb 100644
--- a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html
+++ b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.html
@@ -7,6 +7,7 @@ Edit Assignment
@@ -15,8 +16,23 @@ Edit Assignment
Select Level
+
+ {{ editAssignmentForm.get('level')?.value?.title }}
+
@for(level of hiringService.activeHiringlevels(); track level.id) {
- {{ level.title }}
+
+
+ {{ level.title }}
+ @if(isInstructorPreferred(level)) {
+ school
+ }
+
+
}
diff --git a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts
index 290178b6d..3ad25ae9a 100644
--- a/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts
+++ b/frontend/src/app/hiring/dialogs/edit-assignment-dialog/edit-assignment.dialog.ts
@@ -124,6 +124,12 @@ export class EditAssignmentDialog {
);
}
+ isInstructorPreferred(level: HiringLevel): boolean {
+ const app = this.getApplication();
+ const preferredId = app?.level?.id ?? null;
+ return preferredId != null && level.id === preferredId;
+ }
+
openApplicationDialog(): void {
this.dialog.open(ApplicationDialog, {
height: '600px',
diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.css b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.css
index 5279d2420..0b5abd014 100644
--- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.css
+++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.css
@@ -16,12 +16,20 @@ mat-form-field {
margin-bottom: 16px;
}
-table.priorities > tbody > tr > td.studentPriority,
-table.priorities > tbody > tr > td.instructorPriority {
+.option {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ gap: 8px;
+}
+
+table.priorities>tbody>tr>td.studentPriority,
+table.priorities>tbody>tr>td.instructorPriority {
text-align: center;
}
-table > tbody> tr.selected {
+table>tbody>tr.selected {
color: green;
font-weight: bold;
}
\ No newline at end of file
diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html
index cff54c8c9..9b68e8e7d 100644
--- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html
+++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.html
@@ -18,8 +18,23 @@ Quick Create Assignment
Select Level
+
+ {{ createAssignmentForm.get('level')?.value?.title }}
+
@for(level of hiringService.activeHiringlevels(); track level.id) {
- {{ level.title }}
+
+
+ {{ level.title }}
+ @if(isInstructorPreferred(level)) {
+ school
+ }
+
+
}
diff --git a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts
index c7aec00ba..ef7f47404 100644
--- a/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts
+++ b/frontend/src/app/hiring/dialogs/quick-create-assignment-dialog/quick-create-assignment.dialog.ts
@@ -76,27 +76,39 @@ export class QuickCreateAssignmentDialog {
let review = data.courseAdmin.reviews.find(
(r) => r.applicant_id == data.user.id
)!;
- let program = review.application.program_pursued!;
- let defaultLevelSearch: string | null;
- switch (program) {
- case 'PhD':
- defaultLevelSearch = '1.0 PhD TA';
- break;
- case 'PhD (ABD)':
- defaultLevelSearch = '1.0 PhD (ABD) TA';
- break;
- case 'BS/MS':
- case 'MS':
- defaultLevelSearch = '1.0 MS TA';
- break;
- default:
- defaultLevelSearch = '10h UTA';
- break;
+
+ let level: HiringLevel | undefined;
+
+ // Try to use the instructor's preferred level
+ if (review.level?.id) {
+ level = this.hiringService.getHiringLevel(review.level.id);
}
+
+ // Fall back to title-based search if no preferred level is set or found
+ if (!level) {
+ let program = review.application.program_pursued!;
+ let defaultLevelSearch: string | null;
+ switch (program) {
+ case 'PhD':
+ defaultLevelSearch = '1.0 PhD TA';
+ break;
+ case 'PhD (ABD)':
+ defaultLevelSearch = '1.0 PhD (ABD) TA';
+ break;
+ case 'BS/MS':
+ case 'MS':
+ defaultLevelSearch = '1.0 MS TA';
+ break;
+ default:
+ defaultLevelSearch = '10h UTA';
+ break;
+ }
- const level = this.hiringService
- .hiringLevels()
- .find((level) => level.title == defaultLevelSearch);
+ level = this.hiringService
+ .hiringLevels()
+ .find((level) => level.title == defaultLevelSearch);
+ }
+
if (level) {
this.createAssignmentForm.get('level')?.setValue(level);
}
@@ -169,6 +181,13 @@ export class QuickCreateAssignmentDialog {
);
}
+ /** Returns true if the given level matches the instructor's preferred level for this applicant. */
+ isInstructorPreferred(level: HiringLevel): boolean {
+ const app = this.getApplication();
+ const preferredId = app?.level?.id ?? null;
+ return preferredId != null && level.id === preferredId;
+ }
+
openApplicationDialog(): void {
this.dialog.open(ApplicationDialog, {
height: '600px',
diff --git a/frontend/src/app/hiring/hiring-admin/hiring-admin.component.css b/frontend/src/app/hiring/hiring-admin/hiring-admin.component.css
index c6ceb3311..ce150de9e 100644
--- a/frontend/src/app/hiring/hiring-admin/hiring-admin.component.css
+++ b/frontend/src/app/hiring/hiring-admin/hiring-admin.component.css
@@ -29,11 +29,11 @@ mat-card-header {
tr.example-detail-row {
height: 0;
}
-
+
.example-element-row td {
border-bottom-width: 0;
}
-
+
.example-element-detail {
overflow: hidden;
display: flex;
@@ -60,6 +60,7 @@ course-hiring-card {
.row {
display: flex;
flex-direction: row;
+ align-items: center;
}
.totals-row {
diff --git a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css
index a8ed44757..9b3dd33dd 100644
--- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css
+++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.css
@@ -112,3 +112,10 @@ application-card {
flex-direction: row;
gap: 12px;
}
+
+.coverage-row {
+ display: inline-flex;
+ align-items: center;
+ margin-left: 8px;
+ gap: 4px;
+}
\ No newline at end of file
diff --git a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html
index 323a76b97..4f6b398e8 100644
--- a/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html
+++ b/frontend/src/app/hiring/hiring-preferences/hiring-preferences.component.html
@@ -81,6 +81,28 @@
Preferred
+
+
+ = 0.5
+ ? 'tertiary-background'
+ : 'primary-background')
+ "
+ >{{ coverage.toFixed(2) }}
+
+ @if(coverage <= -0.5) {
+ keyboard_double_arrow_down
+ } @else if(coverage >= 0.5) {
+ keyboard_double_arrow_up
+ } @else {
+ done_all
+ }
+
{
+ this.totalEnrollment = total ?? 0;
+ });
// Load the initial hiring status.
this.hiringService
.getStatus(this.courseSiteId)
@@ -64,6 +78,7 @@ export class HiringPreferencesComponent {
this.notPreferred = hiringStatus.not_preferred;
this.notProcessed = hiringStatus.not_processed;
this.preferred = hiringStatus.preferred;
+ this.recomputeEstimatedCoverage();
});
}
@@ -116,6 +131,7 @@ export class HiringPreferencesComponent {
this.notPreferred = hiringStatus.not_preferred;
this.notProcessed = hiringStatus.not_processed;
this.preferred = hiringStatus.preferred;
+ this.recomputeEstimatedCoverage();
this.isDropProcessing = false;
},
error: (error) => {
@@ -125,6 +141,29 @@ export class HiringPreferencesComponent {
});
}
+ /** Estimate coverage from preferred applicants using selected levels. */
+ private recomputeEstimatedCoverage() {
+ let assignedLoad = 0;
+ for (const review of this.preferred) {
+ const level = review.level;
+ if (!level) {
+ continue;
+ }
+ if (
+ level.classification === HiringLevelClassification.MS ||
+ level.classification === HiringLevelClassification.PHD
+ ) {
+ assignedLoad += level.load;
+ } else if (level.classification === HiringLevelClassification.UG) {
+ assignedLoad += level.load * 0.25;
+ } else {
+ // IOR
+ assignedLoad += 0;
+ }
+ }
+ this.coverage = this.totalEnrollment / 60.0 - assignedLoad;
+ }
+
private saveErrorSnackBar(error: Error) {
let message = 'Error Saving Preferences: ';
if (error instanceof HttpErrorResponse) {
@@ -167,6 +206,7 @@ export class HiringPreferencesComponent {
this.notPreferred = hiringStatus.not_preferred;
this.notProcessed = hiringStatus.not_processed;
this.preferred = hiringStatus.preferred;
+ this.recomputeEstimatedCoverage();
});
});
}
diff --git a/frontend/src/app/hiring/hiring.models.ts b/frontend/src/app/hiring/hiring.models.ts
index 5ba3bd823..ac5ac4883 100644
--- a/frontend/src/app/hiring/hiring.models.ts
+++ b/frontend/src/app/hiring/hiring.models.ts
@@ -40,6 +40,7 @@ export interface ApplicationReviewOverview {
preference: number;
notes: string;
applicant_course_ranking: number;
+ level: HiringLevel | null;
}
export interface HiringStatus {
diff --git a/frontend/src/app/hiring/hiring.service.ts b/frontend/src/app/hiring/hiring.service.ts
index baac830b8..7569b4dbb 100644
--- a/frontend/src/app/hiring/hiring.service.ts
+++ b/frontend/src/app/hiring/hiring.service.ts
@@ -177,4 +177,8 @@ export class HiringService {
`/api/hiring/conflict_check/${applicationId}`
);
}
+
+ getCourseSiteEnrollment(courseSiteId: number): Observable {
+ return this.http.get(`/api/hiring/${courseSiteId}/enrollment`);
+ }
}