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 @@ +
No changes recorded.
+{{ log.change_details }}
+|
- Name
- |
+
+
|---|
Flagged |
-
- {{ element.user.first_name }} {{ element.user.last_name }} |
+ Name |
+
+
+
+ {{ element.user.first_name }} {{ element.user.last_name }} |
Course
|
-
{{ element.course }} {{ element.course }} |
Hiring Level
|
-
+ ${{ element.level.salary.toFixed(2) }} @@ -59,79 +88,73 @@ |
- Epar
- |
- + | Epar |
+
|
- Pos #
- |
- + | Pos # |
+
|
- Notes
- |
- + | Notes |
+
|
- I9?
- |
- + | I9? |
+
|
- Status
- |
- + | Status |
+
|
|---|