diff --git a/backend/api/academics/hiring.py b/backend/api/academics/hiring.py index 6f60a24d5..354f9bc72 100644 --- a/backend/api/academics/hiring.py +++ b/backend/api/academics/hiring.py @@ -15,6 +15,9 @@ from ...models.academics.hiring.hiring_assignment import * from ...models.academics.hiring.hiring_level import * from ...models.academics.hiring.conflict_check import ConflictCheck +from ...models.academics.hiring.hiring_assignment_audit import ( + HiringAssignmentAuditOverview, +) from ...api.authentication import registered_user from ...models.user import User @@ -170,6 +173,7 @@ def get_hiring_summary_overview( page_size: int = 100, order_by: str = "", filter: str = "", + flagged: str = "all", subject: User = Depends(registered_user), hiring_service: HiringService = Depends(), ) -> Paginated[HiringAssignmentSummaryOverview]: @@ -180,7 +184,7 @@ def get_hiring_summary_overview( page=page, page_size=page_size, order_by=order_by, filter=filter ) return hiring_service.get_hiring_summary_overview( - subject, term_id, pagination_params + subject, term_id, flagged, pagination_params ) @@ -407,3 +411,19 @@ def row_iter(): f"attachment; filename=applicants_{term_id}.csv" ) return response + + +@api.get( + "/assignments/{assignment_id}/history", + tags=["Hiring"], + response_model=list[HiringAssignmentAuditOverview], +) +def get_assignment_history( + assignment_id: int, + subject: User = Depends(registered_user), + hiring_service: HiringService = Depends(), +) -> list[HiringAssignmentAuditOverview]: + """ + Get the change history for a specific hiring assignment. + """ + return hiring_service.get_audit_history(subject, assignment_id) diff --git a/backend/entities/__init__.py b/backend/entities/__init__.py index 5ab2a911d..78c5a381a 100644 --- a/backend/entities/__init__.py +++ b/backend/entities/__init__.py @@ -5,11 +5,11 @@ 1. Loads all entities into the application derived from `entities.EntityBase`. In doing so, many of SQLAlchemy's features around metadata (creation/updating/dropping of tables) are possible by virtue of importing from this file directly. - + 2. An index module of all entities which makes importing entities easier. Rather than importing from the modules directly, you can import them from the entities `package`, e.g. `from entities import UserEntity`. - -When adding a new entity to the application be sure to import it here. As a reminder, all identifiers + +When adding a new entity to the application be sure to import it here. As a reminder, all identifiers global to a module are available for import from other modules.""" from .entity_base import EntityBase @@ -33,6 +33,7 @@ from .article_author_entity import article_author_table from .academics.hiring.hiring_assignment_entity import HiringAssignmentEntity +from .academics.hiring.hiring_assignment_audit_entity import HiringAssignmentAuditEntity from .academics.hiring.hiring_level_entity import HiringLevelEntity __authors__ = ["Kris Jordan"] diff --git a/backend/entities/academics/hiring/hiring_assignment_audit_entity.py b/backend/entities/academics/hiring/hiring_assignment_audit_entity.py new file mode 100644 index 000000000..761207249 --- /dev/null +++ b/backend/entities/academics/hiring/hiring_assignment_audit_entity.py @@ -0,0 +1,31 @@ +from sqlalchemy import Integer, String, ForeignKey, DateTime +from sqlalchemy.orm import Mapped, mapped_column, relationship +from datetime import datetime +from ...entity_base import EntityBase + +__authors__ = ["Christian Lee"] +__copyright__ = "Copyright 2025" +__license__ = "MIT" + + +class HiringAssignmentAuditEntity(EntityBase): + """Schema for the `academics__hiring__assignment_audit` table.""" + + __tablename__ = "academics__hiring__assignment_audit" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + + # The assignment being modified + hiring_assignment_id: Mapped[int] = mapped_column( + ForeignKey("academics__hiring__assignment.id") + ) + + # Who made the change + changed_by_user_id: Mapped[int] = mapped_column(ForeignKey("user.id")) + changed_by_user: Mapped["UserEntity"] = relationship("UserEntity") + + # When it happened + change_timestamp: Mapped[datetime] = mapped_column(DateTime, default=datetime.now) + + # What changed (e.g. "Status: Draft -> Commit") + change_details: Mapped[str] = mapped_column(String) diff --git a/backend/entities/academics/hiring/hiring_assignment_entity.py b/backend/entities/academics/hiring/hiring_assignment_entity.py index fffb1da18..b32b2722f 100644 --- a/backend/entities/academics/hiring/hiring_assignment_entity.py +++ b/backend/entities/academics/hiring/hiring_assignment_entity.py @@ -106,6 +106,9 @@ class HiringAssignmentEntity(EntityBase): # Stores the timestamp for the last time the assignment was updated. modified: Mapped[datetime] = mapped_column(DateTime, nullable=False) + # Stores whether the assignment is flagged for further review in the summary. + flagged: Mapped[bool] = mapped_column(Boolean, default=False) + @classmethod def from_draft_model(cls, overview: HiringAssignmentDraft) -> Self: return cls( @@ -119,6 +122,7 @@ def from_draft_model(cls, overview: HiringAssignmentDraft) -> Self: position_number=overview.position_number, epar=overview.epar, i9=overview.i9, + flagged=overview.flagged, notes=overview.notes, created=overview.created, modified=overview.modified, @@ -134,6 +138,7 @@ def to_overview_model(self) -> HiringAssignmentOverview: epar=self.epar, i9=self.i9, notes=self.notes, + flagged=self.flagged, ) def to_summary_overview_model(self) -> HiringAssignmentSummaryOverview: @@ -159,6 +164,7 @@ def to_summary_overview_model(self) -> HiringAssignmentSummaryOverview: epar=self.epar, i9=self.i9, notes=self.notes, + flagged=self.flagged, ) def to_csv_row(self) -> HiringAssignmentCsvRow: diff --git a/backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py b/backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py new file mode 100644 index 000000000..63a5687df --- /dev/null +++ b/backend/migrations/versions/0a57afd03df5_migration_for_applicant_flagging.py @@ -0,0 +1,28 @@ +"""Migration for applicant-flagging + +Revision ID: 0a57afd03df5 +Revises: a9f09b49d862 +Create Date: 2025-11-24 16:09:49.309576 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '0a57afd03df5' +down_revision = 'a9f09b49d862' +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('academics__hiring__assignment', sa.Column('flagged', sa.Boolean(), nullable=False, default=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('academics__hiring__assignment', 'flagged') + # ### end Alembic commands ### diff --git a/backend/migrations/versions/4fb402ba2e0d_add_hiring_assignment_audit_table.py b/backend/migrations/versions/4fb402ba2e0d_add_hiring_assignment_audit_table.py new file mode 100644 index 000000000..6396fb03e --- /dev/null +++ b/backend/migrations/versions/4fb402ba2e0d_add_hiring_assignment_audit_table.py @@ -0,0 +1,44 @@ +"""Add hiring assignment audit table + +Revision ID: 4fb402ba2e0d +Revises: 0a57afd03df5 +Create Date: 2025-12-07 19:02:21.685299 + +""" + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = "4fb402ba2e0d" +down_revision = "0a57afd03df5" + + +def upgrade() -> None: + op.create_table( + "academics__hiring__assignment_audit", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("hiring_assignment_id", sa.Integer(), nullable=False), + sa.Column("changed_by_user_id", sa.Integer(), nullable=False), + sa.Column("change_timestamp", sa.DateTime(), nullable=False), + sa.Column("change_details", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["changed_by_user_id"], + ["user.id"], + name=op.f("fk_academics__hiring__assignment_audit_changed_by_user_id_user"), + ), + sa.ForeignKeyConstraint( + ["hiring_assignment_id"], + ["academics__hiring__assignment.id"], + name=op.f( + "fk_academics__hiring__assignment_audit_hiring_assignment_id_assignment" + ), + ), + sa.PrimaryKeyConstraint( + "id", name=op.f("pk_academics__hiring__assignment_audit") + ), + ) + + +def downgrade() -> None: + op.drop_table("academics__hiring__assignment_audit") diff --git a/backend/models/academics/hiring/hiring_assignment.py b/backend/models/academics/hiring/hiring_assignment.py index 695811060..a18591185 100644 --- a/backend/models/academics/hiring/hiring_assignment.py +++ b/backend/models/academics/hiring/hiring_assignment.py @@ -31,6 +31,7 @@ class HiringAssignmentDraft(BaseModel): epar: str i9: bool notes: str + flagged: bool created: datetime modified: datetime @@ -44,6 +45,7 @@ class HiringAssignmentOverview(BaseModel): epar: str i9: bool notes: str + flagged: bool class HiringAssignmentSummaryOverview(BaseModel): @@ -61,6 +63,7 @@ class HiringAssignmentSummaryOverview(BaseModel): epar: str i9: bool notes: str + flagged: bool class HiringAssignmentCsvRow(BaseModel): diff --git a/backend/models/academics/hiring/hiring_assignment_audit.py b/backend/models/academics/hiring/hiring_assignment_audit.py new file mode 100644 index 000000000..a22431559 --- /dev/null +++ b/backend/models/academics/hiring/hiring_assignment_audit.py @@ -0,0 +1,31 @@ +from pydantic import BaseModel +from datetime import datetime +from ... import PublicUser + +__authors__ = ["Christian Lee"] +__copyright__ = "Copyright 2025" +__license__ = "MIT" + + +class HiringAssignmentAudit(BaseModel): + """ + Pydantic model to represent a snapshot of changes to a Hiring Assignment. + """ + + id: int | None = None + hiring_assignment_id: int + changed_by_user_id: int + change_timestamp: datetime + change_details: str + + +class HiringAssignmentAuditOverview(BaseModel): + """ + Model for displaying audit logs in the UI. + Includes the full user details instead of just an ID. + """ + + id: int + change_timestamp: datetime + change_details: str + changed_by_user: PublicUser diff --git a/backend/services/academics/hiring.py b/backend/services/academics/hiring.py index 15726edda..1de023d2a 100644 --- a/backend/services/academics/hiring.py +++ b/backend/services/academics/hiring.py @@ -28,6 +28,9 @@ from ...entities.section_application_table import section_application_table from ...entities.academics.hiring.hiring_level_entity import HiringLevelEntity from ...entities.academics.hiring.hiring_assignment_entity import HiringAssignmentEntity +from ...entities.academics.hiring.hiring_assignment_audit_entity import ( + HiringAssignmentAuditEntity, +) from ..exceptions import CoursePermissionException, ResourceNotFoundException from ...services import PermissionService @@ -41,6 +44,9 @@ from ...models.academics.hiring.phd_application import PhDApplicationReview from ...models.academics.hiring.hiring_assignment import * from ...models.academics.hiring.hiring_level import * +from ...models.academics.hiring.hiring_assignment_audit import ( + HiringAssignmentAuditOverview, +) __authors__ = ["Ajay Gandecha", "Kris Jordan"] __copyright__ = "Copyright 2024" @@ -731,6 +737,43 @@ def update_hiring_assignment( raise ResourceNotFoundException( f"No hiring assignment with ID: {assignment.id}" ) + changes = [] + # Check Status + if assignment_entity.status != assignment.status: + changes.append( + f"Status: {assignment_entity.status.name} -> {assignment.status.name}" + ) + # Check Flagged + if assignment_entity.flagged != assignment.flagged: + changes.append( + f"Flagged: {assignment_entity.flagged} -> {assignment.flagged}" + ) + # Check Position Number + if assignment_entity.position_number != assignment.position_number: + changes.append( + f"Pos Num: {assignment_entity.position_number} -> {assignment.position_number}" + ) + # Check Notes + old_notes = assignment_entity.notes or "" + new_notes = assignment.notes or "" + + if old_notes != new_notes: + changes.append(f"Notes: '{old_notes}' -> '{new_notes}'") + # Check Hiring Level + if assignment_entity.hiring_level_id != assignment.level.id: + changes.append( + f"Level ID: {assignment_entity.hiring_level_id} -> {assignment.level.id}" + ) + # If we detected changes, save the audit row + if changes: + audit_entry = HiringAssignmentAuditEntity( + hiring_assignment_id=assignment_entity.id, + changed_by_user_id=subject.id, + change_timestamp=datetime.now(), + change_details=", ".join(changes), + ) + self._session.add(audit_entry) + # 3. Update the data and commit assert assignment.level.id is not None assignment_entity.hiring_level_id = assignment.level.id @@ -739,6 +782,7 @@ def update_hiring_assignment( assignment_entity.epar = assignment.epar assignment_entity.i9 = assignment.i9 assignment_entity.notes = assignment.notes + assignment_entity.flagged = assignment.flagged assignment_entity.modified = datetime.now() self._session.commit() @@ -807,7 +851,11 @@ def update_hiring_level(self, subject: User, level: HiringLevel) -> HiringLevel: return level_entity.to_model() def get_hiring_summary_overview( - self, subject: User, term_id: str, pagination_params: PaginationParams + self, + subject: User, + term_id: str, + flagged: str, + pagination_params: PaginationParams, ) -> Paginated[HiringAssignmentSummaryOverview]: """ Returns the hires to show on a summary page for a given term. @@ -815,6 +863,7 @@ def get_hiring_summary_overview( Args: subject: The user making the request term_id: The term to get assignments for + flagged: Filter for flagged assignments ('flagged', 'not_flagged', or 'all') pagination_params: Parameters for pagination and filtering Raises: @@ -851,26 +900,32 @@ def get_hiring_summary_overview( ) base_query = base_query.where(criteria) - # 5. Create count query from base query + # 5. Apply flagged filter if present + if flagged == "flagged": + base_query = base_query.where(HiringAssignmentEntity.flagged.is_(True)) + elif flagged == "not_flagged": + base_query = base_query.where(HiringAssignmentEntity.flagged.is_(False)) + + # 6. Create count query from base query count_query = select(func.count()).select_from(base_query.subquery()) - # 6. Create assignment query with eager loading + # 7. Create assignment query with eager loading assignment_query = base_query.options( joinedload(HiringAssignmentEntity.course_site) .joinedload(CourseSiteEntity.sections) .joinedload(SectionEntity.staff), ) - # 7. Apply pagination + # 8. Apply pagination offset = pagination_params.page * pagination_params.page_size limit = pagination_params.page_size assignment_query = assignment_query.offset(offset).limit(limit) - # 8. Execute queries + # 9. Execute queries length = self._session.scalar(count_query) or 0 assignment_entities = self._session.scalars(assignment_query).unique().all() - # 9. Build and return response + # 10. Build and return response return Paginated( items=[ assignment.to_summary_overview_model() @@ -1212,3 +1267,33 @@ def iter_applicants_for_term_csv(self, subject: User, term_id: str): "preferred_sections": ", ".join(preferred_sections_list), "instructor_selections": instructor_selections_field, } + + def get_audit_history( + self, subject: User, assignment_id: int + ) -> list[HiringAssignmentAuditOverview]: + """ + Retrieves the audit history for a specific hiring assignment. + """ + # 1. Check permissions + self._permission.enforce(subject, "hiring.admin", "*") + + # 2. Query the audit table + query = ( + select(HiringAssignmentAuditEntity) + .where(HiringAssignmentAuditEntity.hiring_assignment_id == assignment_id) + .order_by(HiringAssignmentAuditEntity.change_timestamp.desc()) + .options(joinedload(HiringAssignmentAuditEntity.changed_by_user)) + ) + + audit_entities = self._session.scalars(query).all() + + # 3. Convert to Pydantic models + return [ + HiringAssignmentAuditOverview( + id=audit.id, + change_timestamp=audit.change_timestamp, + change_details=audit.change_details, + changed_by_user=audit.changed_by_user.to_public_model(), + ) + for audit in audit_entities + ] diff --git a/backend/test/services/academics/hiring/hiring_data.py b/backend/test/services/academics/hiring/hiring_data.py index f272da7f6..8feeb28f1 100644 --- a/backend/test/services/academics/hiring/hiring_data.py +++ b/backend/test/services/academics/hiring/hiring_data.py @@ -252,6 +252,23 @@ notes="Some notes here", created=datetime.now(), modified=datetime.now(), + flagged=False, +) + +hiring_assignment_flagged = HiringAssignmentDraft( + id=1, + user_id=user_data.student.id, + term_id=term_data.current_term.id, + course_site_id=office_hours_data.comp_110_site.id, + level=uta_level, + status=HiringAssignmentStatus.COMMIT, + position_number="sample", + epar="12345", + i9=True, + notes="Some notes here", + created=datetime.now(), + modified=datetime.now(), + flagged=True, ) updated_hiring_assignment = HiringAssignmentDraft( @@ -267,6 +284,7 @@ notes="Some notes here", created=datetime.now(), modified=datetime.now(), + flagged=False, ) new_hiring_assignment = HiringAssignmentDraft( @@ -282,9 +300,26 @@ notes="Some notes here", created=datetime.now(), modified=datetime.now(), + flagged=False, +) + +new_flagged_hiring_assignment = HiringAssignmentDraft( + id=3, + user_id=user_data.instructor.id, + term_id=term_data.current_term.id, + course_site_id=office_hours_data.comp_110_site.id, + level=uta_level, + status=HiringAssignmentStatus.FINAL, + position_number="sample", + epar="12345", + i9=True, + notes="Some notes here", + created=datetime.now(), + modified=datetime.now(), + flagged=True, ) -hiring_assignments = [hiring_assignment] +hiring_assignments = [hiring_assignment, new_flagged_hiring_assignment] def insert_fake_data(session: Session): diff --git a/backend/test/services/academics/hiring/hiring_test.py b/backend/test/services/academics/hiring/hiring_test.py index ff8f45e4a..0c7732171 100644 --- a/backend/test/services/academics/hiring/hiring_test.py +++ b/backend/test/services/academics/hiring/hiring_test.py @@ -19,6 +19,9 @@ from .....services.academics import HiringService from .....services.application import ApplicationService from .....services.academics.course_site import CourseSiteService +from .....models.academics.hiring.hiring_assignment_audit import ( + HiringAssignmentAuditOverview, +) # Injected Service Fixtures from .fixtures import hiring_svc @@ -33,6 +36,8 @@ from ...office_hours.office_hours_data import fake_data_fixture as insert_order_5 from .hiring_data import fake_data_fixture as insert_order_6 +from backend.models.pagination import PaginationParams + # Test data from ... import user_data @@ -208,6 +213,15 @@ def test_update_hiring_assignment_not_found(hiring_svc: HiringService): pytest.fail() +def test_update_hiring_assigment_flag(hiring_svc: HiringService): + """Ensures that the admin can update the flagged status of a hiring assignment.""" + assignment = hiring_svc.update_hiring_assignment( + user_data.root, hiring_data.hiring_assignment_flagged + ) + assert assignment is not None + assert assignment.flagged is True + + def test_delete_hiring_assignment(hiring_svc: HiringService): """Ensures that the admin can delete hiring assignments.""" hiring_svc.delete_hiring_assignment( @@ -303,3 +317,117 @@ 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_hiring_summary_overview_all(hiring_svc: HiringService): + """Test that the hiring summary overview returns all assignments.""" + term_id = term_data.current_term.id + pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") + summary = hiring_svc.get_hiring_summary_overview( + user_data.root, term_id, "all", pagination_params + ) + assert summary is not None + assert len(summary.items) > 0 + assert all(assignment.flagged in [True, False] for assignment in summary.items) + + +def test_get_hiring_summary_overview_flagged(hiring_svc: HiringService): + """Test that the hiring summary overview filters for flagged assignments.""" + term_id = term_data.current_term.id + pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") + summary = hiring_svc.get_hiring_summary_overview( + user_data.root, term_id, "flagged", pagination_params + ) + assert summary is not None + assert len(summary.items) > 0 + assert all(assignment.flagged is True for assignment in summary.items) + + +def test_get_hiring_summary_overview_not_flagged(hiring_svc: HiringService): + """Test that the hiring summary overview filters for not flagged assignments.""" + term_id = term_data.current_term.id + pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") + summary = hiring_svc.get_hiring_summary_overview( + user_data.root, term_id, "not_flagged", pagination_params + ) + assert summary is not None + assert len(summary.items) > 0 + assert all(assignment.flagged is False for assignment in summary.items) + + +def test_get_hiring_summary_overview_invalid_flagged(hiring_svc: HiringService): + """Test that an invalid flagged filter returns all flagged/non-flagged assignments.""" + term_id = term_data.current_term.id + pagination_params = PaginationParams(page=0, page_size=10, order_by="", filter="") + summary = hiring_svc.get_hiring_summary_overview( + user_data.root, term_id, "invalid_flagged", pagination_params + ) + + assert len(summary.items) > 0 + assert all(assignment.flagged in [True, False] for assignment in summary.items) + + +def test_update_hiring_assignment_creates_audit_log(hiring_svc: HiringService): + """Ensures that updating an assignment creates an audit log entry.""" + hiring_svc.update_hiring_assignment( + user_data.root, hiring_data.updated_hiring_assignment + ) + + history = hiring_svc.get_audit_history( + user_data.root, hiring_data.hiring_assignment.id + ) + + assert len(history) == 1 + assert history[0].changed_by_user.id == user_data.root.id + assert "Status: COMMIT -> FINAL" in history[0].change_details + + +def test_update_hiring_assignment_audit_details_notes(hiring_svc: HiringService): + """Ensures notes updates are formatted correctly using the 'Old -> New' format.""" + assignment = hiring_data.hiring_assignment.model_copy() + assignment.notes = "New Notes Value" + + hiring_svc.update_hiring_assignment(user_data.root, assignment) + + history = hiring_svc.get_audit_history(user_data.root, assignment.id) + assert len(history) == 1 + assert "Notes: 'Some notes here' -> 'New Notes Value'" in history[0].change_details + + +def test_update_hiring_assignment_audit_details_flagged(hiring_svc: HiringService): + """Ensures flagged status changes are logged.""" + assignment = hiring_data.hiring_assignment.model_copy() + assignment.flagged = True + + hiring_svc.update_hiring_assignment(user_data.root, assignment) + + history = hiring_svc.get_audit_history(user_data.root, assignment.id) + assert len(history) == 1 + assert "Flagged: False -> True" in history[0].change_details + + +def test_get_audit_history_ordering(hiring_svc: HiringService): + """Ensures audit logs are returned in reverse chronological order (newest first).""" + a1 = hiring_data.hiring_assignment.model_copy() + a1.position_number = "update_1" + hiring_svc.update_hiring_assignment(user_data.root, a1) + + a2 = hiring_data.hiring_assignment.model_copy() + a2.position_number = "update_2" + hiring_svc.update_hiring_assignment(user_data.root, a2) + + history = hiring_svc.get_audit_history( + user_data.root, hiring_data.hiring_assignment.id + ) + + assert len(history) == 2 + assert "update_2" in history[0].change_details + assert "update_1" in history[1].change_details + + +def test_get_audit_history_permissions(hiring_svc: HiringService): + """Ensures that non-admins cannot view audit history.""" + with pytest.raises(UserPermissionException): + hiring_svc.get_audit_history( + user_data.student, hiring_data.hiring_assignment.id + ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2745f7dff..87ab2b405 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -336,6 +336,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@angular-devkit/architect/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular-devkit/architect/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -391,7 +406,6 @@ "integrity": "sha512-yuC2vN4VL48JhnsaOa9J/o0Jl+cxOklRNQp5J2/ypMuRROaVCrZAPiX+ChSHh++kHYMpj8+ggNrrUwRNfMKACQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@angular-devkit/core": "18.2.21", "jsonc-parser": "3.3.1", @@ -411,7 +425,6 @@ "integrity": "sha512-Lno6GNbJME85wpc/uqn+wamBxvfZJZFYSH8+oAkkyjU/hk8r5+X8DuyqsKAa0m8t46zSTUsonHsQhVe5vgrZeQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ajv": "8.17.1", "ajv-formats": "3.0.1", @@ -539,6 +552,7 @@ "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.1.tgz", "integrity": "sha512-mexSwaikVE2s+GDhB9fuagEvxbnKHWsqLlO7/R2nY9tTUxBO3drWe3p0D5GxG/EsEyzZU+86ED867q/JmAiVvw==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -689,6 +703,7 @@ "resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-20.2.4.tgz", "integrity": "sha512-5UzrN854pnQH+Qw6XZRxx2zWkcOxKrzWPLXe+gHFxFhxWUZfJKGcTJeAj8bnmyb+C3lqBbGpoNQPQ8pFXQGEaQ==", "license": "MIT", + "peer": true, "dependencies": { "parse5": "^8.0.0", "tslib": "^2.3.0" @@ -897,6 +912,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@angular/cli/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@angular/cli/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -951,6 +981,7 @@ "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.1.tgz", "integrity": "sha512-7Ru3BO4MOBQRMu9GJS+061cUsevKNsNAMxXnQtcqEaNyntUg2v0XiMdv4I7pQGtkQjFK17bKAxQ97jqxJfqsRQ==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -967,6 +998,7 @@ "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.1.tgz", "integrity": "sha512-zRYAdAG/hsJegXapKxElLU6Q5in8UG9Pbxyh90k89qsZwkuv+CfxVY5OBS2xjk1azt808++yhjfvbO/Em+HMKg==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -980,6 +1012,7 @@ "integrity": "sha512-aFfGHi/ApYxmvF4cCS0TypcviQ/Xy+0fwTTrLC8znPC1vObBn0DUA0I6D5dP+xlOTx8PFLkgndNYa2f6RIluvg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/core": "7.28.3", "@jridgewell/sourcemap-codec": "^1.4.14", @@ -1042,6 +1075,7 @@ "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.1.tgz", "integrity": "sha512-O03k9ivZ2CvoHXiXGH5WKlWlTtxF2UGMwGXWnV54vGViHwNcvU5Z3h6Ve6mdU9dYMHK9sGljYZnkRpwI3B8mnQ==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1067,6 +1101,7 @@ "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.1.tgz", "integrity": "sha512-P7cmfK1ldXS8KuPTwwIUTZs5AxhbPNumlumq+nfNJZAxv8/PQJh2W729M/EKHG8rB8cXjoo1K+olExnJNPVDTw==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1102,6 +1137,7 @@ "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.1.tgz", "integrity": "sha512-JiQWRvyVZDH0N9p+pnMOuTFGaw7jPakWDQCJBOBBLdE6AyOiy8YPBImRMrjNNIEqg36h1a8H32rBorf2TL3ExA==", "license": "MIT", + "peer": true, "dependencies": { "tslib": "^2.3.0" }, @@ -1214,6 +1250,7 @@ "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -2461,6 +2498,7 @@ "integrity": "sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@inquirer/checkbox": "^4.2.1", "@inquirer/confirm": "^5.1.14", @@ -4755,6 +4793,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/@schematics/angular/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@schematics/angular/node_modules/rxjs": { "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", @@ -4967,6 +5020,7 @@ "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5024,6 +5078,7 @@ "integrity": "sha512-U7iTAtGgJk6DPX9wIWPPOlt1gO57097G06gIcl0N0EEnNw8RGD62c+2/DiP/zL7KrkqnnqF7gtFGR7YgzPllTA==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.1.0", "@typescript-eslint/types": "8.1.0", @@ -5139,6 +5194,7 @@ "integrity": "sha512-ypRueFNKTIFwqPeJBfeIpxZ895PQhNyH4YID6js0UoBImWYoSjBsahUn9KMiJXh94uOjVBgHD9AmkyPsPnFwJA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "@typescript-eslint/scope-manager": "8.1.0", @@ -5231,6 +5287,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5458,8 +5515,7 @@ "url": "https://feross.org/support" } ], - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/base64id": { "version": "2.0.0", @@ -5520,7 +5576,6 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -5619,6 +5674,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.3", "caniuse-lite": "^1.0.30001741", @@ -5653,7 +5709,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" @@ -5870,6 +5925,7 @@ "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -6023,7 +6079,6 @@ "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.8" } @@ -6262,7 +6317,6 @@ "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "clone": "^1.0.2" }, @@ -6703,6 +6757,7 @@ "integrity": "sha512-JfiKJrbx0506OEerjK2Y1QlldtBxkAlLxT5OEcRF8uaQ86noDe2k31Vw9rnSWv+MXZHj7OOUV/dA0AhdLFcyvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.11.0", @@ -6763,6 +6818,7 @@ "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -8112,8 +8168,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.3.2", @@ -8318,7 +8373,6 @@ "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -8356,7 +8410,6 @@ "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -8499,7 +8552,8 @@ "resolved": "https://registry.npmjs.org/jasmine-core/-/jasmine-core-5.2.0.tgz", "integrity": "sha512-tSAtdrvWybZkQmmaIoDgnvHG8ORUNw5kEVlO5CvrXj02Jjr9TZrmjFq7FUiOUzJiOP2wLGYT6PgrQgQF4R1xiw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/js-tokens": { "version": "4.0.0", @@ -8611,6 +8665,7 @@ "integrity": "sha512-LrtUxbdvt1gOpo3gxG+VAJlJAEMhbWlM4YrFQgql98FwF7+K8K12LYO4hnDdUkNjeztYrOXEMqgTajSWgmtI/w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@colors/colors": "1.5.0", "body-parser": "^1.19.0", @@ -8952,6 +9007,7 @@ "integrity": "sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cli-truncate": "^4.0.0", "colorette": "^2.0.20", @@ -9082,7 +9138,6 @@ "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" @@ -9100,7 +9155,6 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -9117,7 +9171,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -9135,7 +9188,6 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -9361,7 +9413,6 @@ "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } @@ -9539,7 +9590,6 @@ "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" } @@ -10293,7 +10343,6 @@ "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", @@ -10318,7 +10367,6 @@ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "color-convert": "^2.0.1" }, @@ -10335,7 +10383,6 @@ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -10353,7 +10400,6 @@ "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "restore-cursor": "^3.1.0" }, @@ -10367,7 +10413,6 @@ "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "mimic-fn": "^2.1.0" }, @@ -10384,7 +10429,6 @@ "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" @@ -10398,8 +10442,7 @@ "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/ora/node_modules/supports-color": { "version": "7.2.0", @@ -10407,7 +10450,6 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "has-flag": "^4.0.0" }, @@ -10808,6 +10850,7 @@ "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -11089,6 +11132,7 @@ "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -11476,7 +11520,6 @@ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", @@ -11790,6 +11833,7 @@ "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -12246,7 +12290,6 @@ "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">= 8" } @@ -12375,7 +12418,6 @@ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "~5.2.0" } @@ -12695,7 +12737,8 @@ "version": "2.6.3", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tuf-js": { "version": "3.1.0", @@ -12770,6 +12813,7 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -12911,8 +12955,7 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/utils-merge": { "version": "1.0.1", @@ -12961,6 +13004,7 @@ "integrity": "sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -13150,7 +13194,6 @@ "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "defaults": "^1.0.3" } @@ -13425,6 +13468,7 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -13443,7 +13487,8 @@ "version": "0.15.1", "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.15.1.tgz", "integrity": "sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w==", - "license": "MIT" + "license": "MIT", + "peer": true } } } diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css new file mode 100644 index 000000000..07611dadc --- /dev/null +++ b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.css @@ -0,0 +1,71 @@ +mat-form-field { + width: 100%; + margin-top: 10px; +} + +/* Center the loading spinner */ +.loading-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 150px; +} + +/* Center the empty state message */ +.empty-state { + text-align: center; + color: var(--text-secondary, #666); + padding: 40px 0; + font-style: italic; +} + +/* The container for the list items */ +.audit-list { + display: flex; + flex-direction: column; + gap: 16px; + padding-top: 8px; +} + +/* Individual Log Entry */ +.audit-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* Header: Puts Name on left, Date on right */ +.audit-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 4px; +} + +/* Name Style */ +.audit-user { + font-weight: 600; /* Semibold equivalent */ + font-size: 1rem; +} + +/* Date Badge Style */ +.audit-time { + font-size: 0.85rem; + color: var(--text-secondary, #666); + background-color: #f5f5f5; /* Light gray background */ + padding: 2px 8px; + border-radius: 12px; +} + +/* The actual text of what changed */ +.audit-detail { + margin: 0; + color: var(--text-primary, #333); + line-height: 1.4; + white-space: pre-wrap; /* Keeps formatting if you use newlines later */ +} + +/* Fix the divider spacing */ +mat-divider { + margin-top: 12px; +} \ No newline at end of file diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html new file mode 100644 index 000000000..e80e5278b --- /dev/null +++ b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.html @@ -0,0 +1,40 @@ +

Audit Log: {{ data.applicantName }}

+ + + @if(!(history$ | async)) { +
+ +
+ } + + @else { + @let history = (history$ | async) ?? []; + + @if(history.length === 0) { +
+

No changes recorded.

+
+ } @else { +
+ @for(log of history; track log.id) { +
+
+ + {{ log.changed_by_user.first_name }} {{ log.changed_by_user.last_name }} + + + {{ log.change_timestamp | date:'medium' }} + +
+

{{ log.change_details }}

+ +
+ } +
+ } + } +
+ + + + \ No newline at end of file diff --git a/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts new file mode 100644 index 000000000..c4dd9af9c --- /dev/null +++ b/frontend/src/app/hiring/dialogs/audit-log-dialog/audit-log-dialog.dialog.ts @@ -0,0 +1,36 @@ +/** + * @author Christian Lee + * @copyright 2025 + * @license MIT + */ + +import { Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { HiringService } from '../../hiring.service'; +import { HiringAssignmentAuditOverview } from '../../hiring.models'; +import { Observable } from 'rxjs'; + +export interface AuditLogDialogData { + assignmentId: number; + applicantName: string; +} + +@Component({ + selector: 'app-audit-log-dialog', + templateUrl: './audit-log-dialog.dialog.html', + styleUrls: ['./audit-log-dialog.dialog.css'], + standalone: false +}) +export class AuditLogDialog implements OnInit { + history$: Observable | undefined; + + constructor( + protected dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: AuditLogDialogData, + private hiringService: HiringService + ) {} + + ngOnInit() { + this.history$ = this.hiringService.getAuditHistory(this.data.assignmentId); + } +} diff --git a/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts b/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts index 824bd1113..e008807ec 100644 --- a/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts +++ b/frontend/src/app/hiring/dialogs/create-assignment-dialog/create-assignment.dialog.ts @@ -81,6 +81,7 @@ export class CreateAssignmentDialog { epar: this.createAssignmentForm.get('epar')!.value ?? '', i9: this.createAssignmentForm.get('i9')!.value ?? false, notes: this.createAssignmentForm.get('notes')!.value ?? '', + flagged: false, created: new Date(), modified: new Date() }; 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..3eb790585 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 @@ -97,6 +97,7 @@ export class EditAssignmentDialog { epar: this.editAssignmentForm.get('epar')!.value ?? '', i9: this.editAssignmentForm.get('i9')!.value ?? false, notes: this.editAssignmentForm.get('notes')!.value ?? '', + flagged: false, created: new Date(), // Will be overwritten anyway modified: new Date() }; 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..a4f8cfd83 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 @@ -142,6 +142,7 @@ export class QuickCreateAssignmentDialog { epar: this.createAssignmentForm.get('epar')!.value ?? '', i9: this.createAssignmentForm.get('i9')!.value ?? false, notes: this.createAssignmentForm.get('notes')!.value ?? '', + flagged: false, created: new Date(), modified: new Date() }; diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css index 6f542b9c9..988dbbdbd 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.css @@ -14,6 +14,9 @@ mat-card-header { .header-actions { margin-left: auto; + display: flex; + align-items: center; + gap: 0.5em; } .term-selector { @@ -41,4 +44,30 @@ mat-form-field.pos_number { max-width: 100% !important; margin-right: 32px !important; } +} + +.flag-icon-btn { + transition: color 0.15s ease; +} + +.flag-selected { + font-variation-settings: + 'FILL' 1; +} + +/* Add this new style for the cell */ +.clickable-cell { + cursor: pointer; + font-weight: 500; + color: var(--primary-color, #007bb2); /* Optional: make it look like a link */ +} + +.clickable-cell:hover { + text-decoration: underline; +} + +.pid-text { + font-weight: normal; + color: black; + font-size: 0.9em; } \ No newline at end of file diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html index c9fe1bf9b..a8d48868c 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.html @@ -8,7 +8,16 @@ {{ selectedTerm().name }} Onboarding
- + + All + Flagged + Not Flagged + + + Select Term
- - - + +
-
Name
-
+ + + + + + + + + + + - - + + - - + + - - + + - - + + - - + +
Flagged
-
-

{{ element.user.first_name }} {{ element.user.last_name }}
{{ element.user.pid }}

+ +
Name
+
+

+ {{ element.user.first_name }} {{ element.user.last_name }}
{{ element.user.pid }} +

Course
-

{{ element.course }}
+

{{ element.course }}
{{ element.instructors }}

Hiring Level
-

+

${{ element.level.salary.toFixed(2) }} @@ -59,79 +88,73 @@

-
Epar
-
+
Epar
-
Pos #
-
+
Pos #
-
Notes
-
+
Notes
+ [(ngModel)]="element.notes" + (change)="updateAssignment({ assignment: element })">
-
I9?
-
+
I9?
+ [(ngModel)]="element.i9" + (change)="updateAssignment({ assignment: element })" /> -
Status
-
+
Status
+ [(ngModel)]="element.status" + (change)="updateAssignment({ assignment: element })"> Commit Final
@@ -142,4 +165,4 @@ (page)="handlePageEvent($event)">
- + \ No newline at end of file diff --git a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts index 70d502005..7a1aed929 100644 --- a/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts +++ b/frontend/src/app/hiring/hiring-summary/hiring-summary.component.ts @@ -19,6 +19,9 @@ import { HiringAssignmentSummaryOverview } from '../hiring.models'; import { PageEvent } from '@angular/material/paginator'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatDialog } from '@angular/material/dialog'; +import { AuditLogDialog } from '../dialogs/audit-log-dialog/audit-log-dialog.dialog'; const DEFAULT_PAGINATION_PARAMS = { page: 0, @@ -28,10 +31,10 @@ const DEFAULT_PAGINATION_PARAMS = { } as PaginationParams; @Component({ - selector: 'app-hiring-summary', - templateUrl: './hiring-summary.component.html', - styleUrl: './hiring-summary.component.css', - standalone: false + selector: 'app-hiring-summary', + templateUrl: './hiring-summary.component.html', + styleUrl: './hiring-summary.component.css', + standalone: false }) export class HiringSummaryComponent { /** Route for the routing module */ @@ -53,22 +56,30 @@ export class HiringSummaryComponent { return this.terms.find((term) => term.id === this.selectedTermId())!; }); + /** Current filter mode for applicants*/ + public filterMode: WritableSignal<'all' | 'flagged' | 'not_flagged'> = + signal('all'); + /** Effect that updates the hiring data when the selected term changes. */ selectedTermEffect = effect(() => { - if (this.selectedTermId()) { - const term = this.terms.find( - (term) => term.id === this.selectedTermId() - )!; - // Load paginated data - this.assignmentsPaginator.changeApiRoute( - `/api/hiring/summary/${term.id}` - ); - - this.assignmentsPaginator - .loadPage(this.previousPaginationParams) - .subscribe((page) => { - this.assignmentsPage.set(page); - }); + const termId = this.selectedTermId(); + // We check for termId existence to avoid running this before data is ready + if (termId) { + // Pass the current filter mode to the URL construction + this.updatePaginatorUrl(termId, this.filterMode()); + this.refreshData(); + } + }); + + /** Effect that updates the hiring data when the filter mode changes. */ + filterModeEffect = effect(() => { + const mode = this.filterMode(); + const termId = this.selectedTermId(); + + // Only update if we have a term selected + if (termId) { + this.updatePaginatorUrl(termId, mode); + this.refreshData(); } }); @@ -81,6 +92,7 @@ export class HiringSummaryComponent { DEFAULT_PAGINATION_PARAMS; public displayedColumns: string[] = [ + 'flagged', 'name', 'course', 'level', @@ -103,17 +115,16 @@ export class HiringSummaryComponent { paginationParams.filter = this.searchBarQuery(); // Refresh the data - this.assignmentsPaginator.loadPage(paginationParams).subscribe((page) => { - this.assignmentsPage.set(page); - this.previousPaginationParams = paginationParams; - }); + this.refreshData(paginationParams); }); /** Constructor */ constructor( private route: ActivatedRoute, + private snackbar: MatSnackBar, protected hiringService: HiringService, - protected academicsService: AcademicsService + protected academicsService: AcademicsService, + private dialog: MatDialog ) { // Initialize data from resolvers const data = this.route.snapshot.data as { @@ -122,18 +133,38 @@ export class HiringSummaryComponent { }; this.terms = data.terms; - this.selectedTermId.set(data.currentTerm?.id ?? undefined); + const termId = data.currentTerm?.id; + this.selectedTermId.set(termId); // Load paginated data + const initialUrl = termId + ? `/api/hiring/summary/${termId}?flagged=${this.filterMode()}` + : ''; + this.assignmentsPaginator = new Paginator( - `/api/hiring/summary/${data.currentTerm!.id}` + initialUrl ); - this.assignmentsPaginator - .loadPage(this.previousPaginationParams) - .subscribe((page) => { - this.assignmentsPage.set(page); - }); + if (termId) { + this.refreshData(); + } + } + + /** Helper to update the paginator API URL with term and flag params */ + private updatePaginatorUrl(termId: string, flagMode: string) { + this.assignmentsPaginator.changeApiRoute( + `/api/hiring/summary/${termId}?flagged=${flagMode}` + ); + } + + /** Helper to trigger the loadPage logic */ + private refreshData( + params: PaginationParams = this.previousPaginationParams + ) { + this.assignmentsPaginator.loadPage(params).subscribe((page) => { + this.assignmentsPage.set(page); + this.previousPaginationParams = params; + }); } /** Handles a pagination event for the future office hours table */ @@ -141,15 +172,30 @@ export class HiringSummaryComponent { let paginationParams = this.assignmentsPage()!.params; paginationParams.page = e.pageIndex; paginationParams.page_size = e.pageSize; - this.assignmentsPaginator.loadPage(paginationParams).subscribe((page) => { - this.assignmentsPage.set(page); - this.previousPaginationParams = paginationParams; + this.refreshData(paginationParams); + } + + toggleAssignmentFlag(assignment: HiringAssignmentSummaryOverview) { + // Optimistically update the flag state + assignment.flagged = !assignment.flagged; + // Call the service to update. If there is an error, we will rollback + // the optimistic update and display a snackbar notification. + this.updateAssignment({ + assignment, + onError: () => { + assignment.flagged = !assignment.flagged; + } }); } - /** Save changes */ - updateAssignment(assignmentIndex: number) { - let assignment = this.assignmentsPage()!.items[assignmentIndex]!; + /** Handles updating an assignment for all inputs */ + updateAssignment({ + assignment, + onError + }: { + assignment: HiringAssignmentSummaryOverview; + onError?: () => void; + }) { let draft: HiringAssignmentDraft = { id: assignment.id, user_id: assignment.user.id, @@ -162,10 +208,52 @@ export class HiringSummaryComponent { epar: assignment.epar, i9: assignment.i9, notes: assignment.notes, + flagged: assignment.flagged, created: new Date(), // will be overrided modified: new Date() }; - this.hiringService.updateHiringAssignment(draft).subscribe((_) => {}); + // Call the service to update. If there is an error, we will rollback + // the optimistic update and display a snackbar notification. + this.hiringService.updateHiringAssignment(draft).subscribe({ + error: (_) => { + onError?.(); + this.snackbar.open( + 'Failed to update assignment. Please try again.', + 'Close', + { duration: 5000 } + ); + } + }); + } + + /** Opens the audit log dialog for a specific assignment */ + openAuditLog( + assignment: HiringAssignmentSummaryOverview, + event?: MouseEvent + ) { + if (event) { + event.stopPropagation(); + } + + this.dialog.open(AuditLogDialog, { + data: { + assignmentId: assignment.id, + applicantName: `${assignment.user.first_name} ${assignment.user.last_name}` + }, + width: '600px' + }); + } + + /** Gets ordered hiring assignments */ + getOrderedAssignments(): HiringAssignmentSummaryOverview[] { + const assignments = this.assignmentsPage()?.items ?? []; + if (assignments.length === 0) { + return []; + } + + return [...assignments].sort( + (a, b) => Number(b.flagged) - Number(a.flagged) + ); } /** Export CSV button pressed */ diff --git a/frontend/src/app/hiring/hiring.models.ts b/frontend/src/app/hiring/hiring.models.ts index 5ba3bd823..ae1028a09 100644 --- a/frontend/src/app/hiring/hiring.models.ts +++ b/frontend/src/app/hiring/hiring.models.ts @@ -90,6 +90,7 @@ export interface HiringAssignmentDraft { notes: string; created: Date; modified: Date; + flagged: boolean; } export interface HiringAssignmentOverview { @@ -101,6 +102,7 @@ export interface HiringAssignmentOverview { epar: string; i9: boolean; notes: string; + flagged: boolean; } export const hiringAssignmentOverviewToDraft = ( @@ -121,6 +123,7 @@ export const hiringAssignmentOverviewToDraft = ( epar: assignment.epar, i9: assignment.i9, notes: assignment.notes, + flagged: assignment.flagged, created: new Date(), // overwritten anyway modified: new Date() // overwritten anyway }; @@ -161,6 +164,7 @@ export interface HiringAssignmentSummaryOverview { epar: string; i9: boolean; notes: string; + flagged: boolean; } export interface ReleasedHiringAssignment { @@ -181,3 +185,10 @@ export interface ConflictCheck { assignments: HiringAssignmentSummaryOverview[]; priorities: ApplicationPriority[]; } + +export interface HiringAssignmentAuditOverview { + id: number; + change_timestamp: Date; + change_details: string; + changed_by_user: PublicProfile; +} diff --git a/frontend/src/app/hiring/hiring.module.ts b/frontend/src/app/hiring/hiring.module.ts index 057f8dabb..94f937ea2 100644 --- a/frontend/src/app/hiring/hiring.module.ts +++ b/frontend/src/app/hiring/hiring.module.ts @@ -53,6 +53,8 @@ import { HiringSummaryComponent } from './hiring-summary/hiring-summary.componen import { MatCheckboxModule } from '@angular/material/checkbox'; import { HiringAssignmentsComponent } from './hiring-assignments/hiring-assignments.component'; import { HiringPageComponent } from './hiring-page/hiring-page.component'; +import { AuditLogDialog } from './dialogs/audit-log-dialog/audit-log-dialog.dialog'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; /* UI Widgets */ @@ -70,7 +72,8 @@ import { HiringPageComponent } from './hiring-page/hiring-page.component'; EditAssignmentDialog, HiringSummaryComponent, HiringAssignmentsComponent, - HiringPageComponent + HiringPageComponent, + AuditLogDialog ], imports: [ CommonModule, @@ -100,7 +103,8 @@ import { HiringPageComponent } from './hiring-page/hiring-page.component'; MatButtonToggleModule, MatChipsModule, MatSlideToggleModule, - MatCheckboxModule + MatCheckboxModule, + MatProgressSpinnerModule ] }) export class HiringModule {} diff --git a/frontend/src/app/hiring/hiring.service.ts b/frontend/src/app/hiring/hiring.service.ts index baac830b8..4c03ac21b 100644 --- a/frontend/src/app/hiring/hiring.service.ts +++ b/frontend/src/app/hiring/hiring.service.ts @@ -16,6 +16,7 @@ import { HiringAdminOverview, HiringAssignmentDraft, HiringAssignmentOverview, + HiringAssignmentAuditOverview, HiringLevel, HiringStatus } from './hiring.models'; @@ -72,6 +73,17 @@ export class HiringService { ); } + /** + * Returns recent changes made to the assignment + * @param assignmentId ID of the assignment to fetch history for + * @returns Observable list of audit logs + */ + getAuditHistory(assignmentId: number) { + return this.http.get( + `/api/hiring/assignments/${assignmentId}/history` + ); + } + private hiringLevelsSignal: WritableSignal = signal([]); hiringLevels = this.hiringLevelsSignal.asReadonly(); activeHiringlevels = computed(() => { diff --git a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html index 20a33a280..99debd8d5 100644 --- a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html +++ b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.html @@ -9,12 +9,20 @@ class="surface-container-card"> Hire - - + + + + Avatar + {{ element.user.first_name }} {{ element.user.last_name }} + + @@ -67,12 +75,16 @@ Draft Commit Final - +
diff --git a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts index 0762d62f2..58978488f 100644 --- a/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts +++ b/frontend/src/app/hiring/widgets/course-hiring-card/course-hiring-card.widget.ts @@ -32,12 +32,13 @@ import { EditAssignmentDialogData } from '../../dialogs/edit-assignment-dialog/edit-assignment.dialog'; import { ApplicationDialog } from '../../dialogs/application-dialog/application-dialog.dialog'; +import { AuditLogDialog } from '../../dialogs/audit-log-dialog/audit-log-dialog.dialog'; @Component({ - selector: 'course-hiring-card', - templateUrl: './course-hiring-card.widget.html', - styleUrl: './course-hiring-card.widget.css', - standalone: false + selector: 'course-hiring-card', + templateUrl: './course-hiring-card.widget.html', + styleUrl: './course-hiring-card.widget.css', + standalone: false }) export class CourseHiringCardWidget implements OnInit { @Input() termId!: string; @@ -167,6 +168,19 @@ export class CourseHiringCardWidget implements OnInit { }); } + /** Opens the audit log dialog for a specific assignment. */ + openAuditLog(assignment: HiringAssignmentOverview, event: MouseEvent): void { + event.stopPropagation(); + + this.dialog.open(AuditLogDialog, { + width: '600px', + data: { + assignmentId: assignment.id, + applicantName: `${assignment.user.first_name} ${assignment.user.last_name}` + } + }); + } + chipSelected(user: PublicProfile): boolean { return ( this.item()! diff --git a/frontend/src/app/pagination.ts b/frontend/src/app/pagination.ts index 61d888f56..6e0753ce9 100644 --- a/frontend/src/app/pagination.ts +++ b/frontend/src/app/pagination.ts @@ -123,9 +123,11 @@ abstract class PaginatorAbstraction { // Stpres the previous pagination parameters used this.previousParams = paramStrings; + const separator = this.api.includes('?') ? '&' : '?'; + // Determines the query for the URL based on the new paramateres. let query = new URLSearchParams(paramStrings); - let route = this.api + '?' + query.toString(); + let route = this.api + separator + query.toString(); // Determine if an operator function is necessary if (operator) {