From aaabf8260ed885b77a42af7759e8b15850ccd82c Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 16:37:44 -0700
Subject: [PATCH 01/81] feat: add pre-award approval response fields to
database
Add database fields and model support for approval response workflow:
- pre_award_approval_status (VARCHAR(20): APPROVED/DECLINED)
- pre_award_approval_responded_by (FK to ops_user.id)
- pre_award_approval_responded_date (DATE)
- pre_award_approval_reviewer_notes (TEXT)
Updates:
- Add columns to procurement_tracker_step and _version tables
- Add relationship pre_award_approval_responded_by_user
- Update to_dict() to map new fields for PRE_AWARD steps
- Add comprehensive tests for field existence, setting, and serialization
Co-Authored-By: Claude Sonnet 4.5
---
..._add_pre_award_approval_response_fields.py | 50 +++++++
backend/models/procurement_tracker.py | 46 +++++++
.../test_procurement_tracker_model.py | 124 ++++++++++++++++++
3 files changed, 220 insertions(+)
create mode 100644 backend/alembic/versions/2026_03_23_1600-d6e7f8a9b0c1_add_pre_award_approval_response_fields.py
diff --git a/backend/alembic/versions/2026_03_23_1600-d6e7f8a9b0c1_add_pre_award_approval_response_fields.py b/backend/alembic/versions/2026_03_23_1600-d6e7f8a9b0c1_add_pre_award_approval_response_fields.py
new file mode 100644
index 0000000000..1ae5b25b2a
--- /dev/null
+++ b/backend/alembic/versions/2026_03_23_1600-d6e7f8a9b0c1_add_pre_award_approval_response_fields.py
@@ -0,0 +1,50 @@
+"""Add pre_award approval response fields to procurement tracker step
+
+Revision ID: d6e7f8a9b0c1
+Revises: c9a1b2d3e4f5
+Create Date: 2026-03-23 16:00:00.000000+00:00
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'd6e7f8a9b0c1'
+down_revision: Union[str, None] = 'c9a1b2d3e4f5'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('procurement_tracker_step', sa.Column('pre_award_approval_status', sa.String(length=20), nullable=True))
+ op.add_column('procurement_tracker_step', sa.Column('pre_award_approval_responded_by', sa.Integer(), nullable=True))
+ op.add_column('procurement_tracker_step', sa.Column('pre_award_approval_responded_date', sa.Date(), nullable=True))
+ op.add_column('procurement_tracker_step', sa.Column('pre_award_approval_reviewer_notes', sa.Text(), nullable=True))
+ op.create_foreign_key(
+ 'fk_pre_award_responded_by',
+ 'procurement_tracker_step', 'ops_user',
+ ['pre_award_approval_responded_by'], ['id']
+ )
+ op.add_column('procurement_tracker_step_version', sa.Column('pre_award_approval_status', sa.String(length=20), autoincrement=False, nullable=True))
+ op.add_column('procurement_tracker_step_version', sa.Column('pre_award_approval_responded_by', sa.Integer(), autoincrement=False, nullable=True))
+ op.add_column('procurement_tracker_step_version', sa.Column('pre_award_approval_responded_date', sa.Date(), autoincrement=False, nullable=True))
+ op.add_column('procurement_tracker_step_version', sa.Column('pre_award_approval_reviewer_notes', sa.Text(), autoincrement=False, nullable=True))
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_column('procurement_tracker_step_version', 'pre_award_approval_reviewer_notes')
+ op.drop_column('procurement_tracker_step_version', 'pre_award_approval_responded_date')
+ op.drop_column('procurement_tracker_step_version', 'pre_award_approval_responded_by')
+ op.drop_column('procurement_tracker_step_version', 'pre_award_approval_status')
+ op.drop_constraint('fk_pre_award_responded_by', 'procurement_tracker_step', type_='foreignkey')
+ op.drop_column('procurement_tracker_step', 'pre_award_approval_reviewer_notes')
+ op.drop_column('procurement_tracker_step', 'pre_award_approval_responded_date')
+ op.drop_column('procurement_tracker_step', 'pre_award_approval_responded_by')
+ op.drop_column('procurement_tracker_step', 'pre_award_approval_status')
+ # ### end Alembic commands ###
diff --git a/backend/models/procurement_tracker.py b/backend/models/procurement_tracker.py
index c64fe73784..3747c553ad 100644
--- a/backend/models/procurement_tracker.py
+++ b/backend/models/procurement_tracker.py
@@ -392,6 +392,25 @@ class DefaultProcurementTrackerStep(ProcurementTrackerStep):
nullable=True,
)
+ # PRE_AWARD approval response fields
+ pre_award_approval_status: Mapped[Optional[str]] = mapped_column(
+ String(20),
+ nullable=True,
+ )
+ pre_award_approval_responded_by: Mapped[Optional[int]] = mapped_column(
+ Integer,
+ ForeignKey("ops_user.id"),
+ nullable=True,
+ )
+ pre_award_approval_responded_date: Mapped[Optional[date]] = mapped_column(
+ Date,
+ nullable=True,
+ )
+ pre_award_approval_reviewer_notes: Mapped[Optional[str]] = mapped_column(
+ Text,
+ nullable=True,
+ )
+
# Relationship for pre_award completed by user
pre_award_completed_by_user: Mapped[Optional["User"]] = relationship(
"User",
@@ -406,6 +425,13 @@ class DefaultProcurementTrackerStep(ProcurementTrackerStep):
viewonly=True,
)
+ # Relationship for pre_award approval responded by user
+ pre_award_approval_responded_by_user: Mapped[Optional["User"]] = relationship(
+ "User",
+ foreign_keys=[pre_award_approval_responded_by],
+ viewonly=True,
+ )
+
# Polymorphic configuration
__mapper_args__ = {
"polymorphic_identity": "default_step",
@@ -589,8 +615,13 @@ def to_dict(self):
data.pop("pre_award_approval_requested_date", None)
data.pop("pre_award_approval_requested_by", None)
data.pop("pre_award_requestor_notes", None)
+ data.pop("pre_award_approval_status", None)
+ data.pop("pre_award_approval_responded_by", None)
+ data.pop("pre_award_approval_responded_date", None)
+ data.pop("pre_award_approval_reviewer_notes", None)
data.pop("pre_award_completed_by_user", None)
data.pop("pre_award_requested_by_user", None)
+ data.pop("pre_award_approval_responded_by_user", None)
# Handle PRE_AWARD-specific fields
elif self.step_type == ProcurementTrackerStepType.PRE_AWARD:
@@ -606,6 +637,12 @@ def to_dict(self):
data["approval_requested_by"] = data.pop("pre_award_approval_requested_by", None)
data["requestor_notes"] = data.pop("pre_award_requestor_notes", None)
+ # Map approval response fields
+ data["approval_status"] = data.pop("pre_award_approval_status", None)
+ data["approval_responded_by"] = data.pop("pre_award_approval_responded_by", None)
+ data["approval_responded_date"] = data.pop("pre_award_approval_responded_date", None)
+ data["reviewer_notes"] = data.pop("pre_award_approval_reviewer_notes", None)
+
# Map the relationship
if "pre_award_completed_by_user" in data:
data["completed_by_user"] = data.pop("pre_award_completed_by_user", None)
@@ -614,6 +651,10 @@ def to_dict(self):
if "pre_award_requested_by_user" in data:
data["requested_by_user"] = data.pop("pre_award_requested_by_user", None)
+ # Map the approval responded by user relationship
+ if "pre_award_approval_responded_by_user" in data:
+ data["approval_responded_by_user"] = data.pop("pre_award_approval_responded_by_user", None)
+
# Remove ACQUISITION_PLANNING-specific fields
data.pop("acquisition_planning_task_completed_by", None)
data.pop("acquisition_planning_date_completed", None)
@@ -678,8 +719,13 @@ def to_dict(self):
data.pop("pre_award_approval_requested_date", None)
data.pop("pre_award_approval_requested_by", None)
data.pop("pre_award_requestor_notes", None)
+ data.pop("pre_award_approval_status", None)
+ data.pop("pre_award_approval_responded_by", None)
+ data.pop("pre_award_approval_responded_date", None)
+ data.pop("pre_award_approval_reviewer_notes", None)
data.pop("pre_award_completed_by_user", None)
data.pop("pre_award_requested_by_user", None)
+ data.pop("pre_award_approval_responded_by_user", None)
return data
diff --git a/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_model.py b/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_model.py
index 2a6401392e..963541f8d5 100644
--- a/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_model.py
+++ b/backend/ops_api/tests/ops/procurement_tracker/test_procurement_tracker_model.py
@@ -576,3 +576,127 @@ def test_step_to_dict_maps_pre_award_approval_fields(loaded_db, test_user):
assert "pre_award_approval_requested_date" not in step_5_dict
assert "pre_award_approval_requested_by" not in step_5_dict
assert "pre_award_requestor_notes" not in step_5_dict
+
+
+def test_pre_award_approval_response_fields_exist(loaded_db):
+ """Test that pre_award approval response fields exist on all steps."""
+ tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1)
+ loaded_db.add(tracker)
+ loaded_db.commit()
+
+ # All steps have the prefixed approval response columns
+ for step in tracker.steps:
+ assert hasattr(step, "pre_award_approval_status")
+ assert hasattr(step, "pre_award_approval_responded_by")
+ assert hasattr(step, "pre_award_approval_responded_date")
+ assert hasattr(step, "pre_award_approval_reviewer_notes")
+
+
+def test_pre_award_approval_response_fields_can_be_set(loaded_db, test_user):
+ """Test that pre_award approval response fields can be set and retrieved."""
+ tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1)
+ loaded_db.add(tracker)
+ loaded_db.commit()
+
+ # Get step 5 (PRE_AWARD)
+ step_5 = tracker.steps[4]
+ step_5_id = step_5.id
+
+ # Set approval response fields
+ step_5.pre_award_approval_status = "APPROVED"
+ step_5.pre_award_approval_responded_by = test_user.id
+ step_5.pre_award_approval_responded_date = date(2026, 3, 16)
+ step_5.pre_award_approval_reviewer_notes = "Approved - looks good"
+ loaded_db.commit()
+
+ # Fetch it again by ID
+ from models import ProcurementTrackerStep
+
+ updated_step = loaded_db.get(ProcurementTrackerStep, step_5_id)
+ assert updated_step.pre_award_approval_status == "APPROVED"
+ assert updated_step.pre_award_approval_responded_by == test_user.id
+ assert updated_step.pre_award_approval_responded_date == date(2026, 3, 16)
+ assert updated_step.pre_award_approval_reviewer_notes == "Approved - looks good"
+
+
+def test_pre_award_approval_responded_by_user_relationship(loaded_db, test_user):
+ """Test the relationship to ops_user via pre_award_approval_responded_by_user."""
+ tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1)
+ loaded_db.add(tracker)
+ loaded_db.commit()
+
+ # Get step 5 (PRE_AWARD)
+ step_5 = tracker.steps[4]
+
+ # Set approval response user
+ step_5.pre_award_approval_responded_by = test_user.id
+ loaded_db.commit()
+
+ # Refresh to load relationship
+ loaded_db.refresh(step_5)
+
+ # Verify relationship
+ assert step_5.pre_award_approval_responded_by_user is not None
+ assert step_5.pre_award_approval_responded_by_user.id == test_user.id
+
+
+def test_step_to_dict_maps_pre_award_approval_response_fields(loaded_db, test_user):
+ """Test that to_dict() maps approval response fields to API field names for PRE_AWARD."""
+ tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1)
+ loaded_db.add(tracker)
+ loaded_db.commit()
+
+ # Update step 5 with both request and response data
+ step_5 = tracker.steps[4]
+ step_5.pre_award_approval_requested = True
+ step_5.pre_award_approval_requested_date = date(2026, 3, 15)
+ step_5.pre_award_approval_requested_by = test_user.id
+ step_5.pre_award_requestor_notes = "Please review"
+ step_5.pre_award_approval_status = "APPROVED"
+ step_5.pre_award_approval_responded_by = test_user.id
+ step_5.pre_award_approval_responded_date = date(2026, 3, 16)
+ step_5.pre_award_approval_reviewer_notes = "Approved - all good"
+ loaded_db.commit()
+
+ # Step 5 (PRE_AWARD) should map to unprefixed names
+ step_5_dict = tracker.steps[4].to_dict()
+
+ # Check approval request fields
+ assert "approval_requested" in step_5_dict
+ assert step_5_dict["approval_requested"] is True
+ assert "requestor_notes" in step_5_dict
+ assert step_5_dict["requestor_notes"] == "Please review"
+
+ # Check approval response fields
+ assert "approval_status" in step_5_dict
+ assert "approval_responded_by" in step_5_dict
+ assert "approval_responded_date" in step_5_dict
+ assert "reviewer_notes" in step_5_dict
+ assert step_5_dict["approval_status"] == "APPROVED"
+ assert step_5_dict["approval_responded_by"] == test_user.id
+ assert step_5_dict["approval_responded_date"] == "2026-03-16"
+ assert step_5_dict["reviewer_notes"] == "Approved - all good"
+
+ # Prefixed versions should be removed
+ assert "pre_award_approval_status" not in step_5_dict
+ assert "pre_award_approval_responded_by" not in step_5_dict
+ assert "pre_award_approval_responded_date" not in step_5_dict
+ assert "pre_award_approval_reviewer_notes" not in step_5_dict
+
+
+def test_step_to_dict_excludes_pre_award_approval_response_fields_from_other_steps(loaded_db):
+ """Test that approval response fields are excluded from non-PRE_AWARD steps."""
+ tracker = DefaultProcurementTracker.create_with_steps(agreement_id=1)
+ loaded_db.add(tracker)
+ loaded_db.commit()
+
+ # Check step 1 (ACQUISITION_PLANNING)
+ step_1_dict = tracker.steps[0].to_dict()
+ assert "approval_status" not in step_1_dict
+ assert "approval_responded_by" not in step_1_dict
+ assert "approval_responded_date" not in step_1_dict
+ assert "reviewer_notes" not in step_1_dict
+ assert "pre_award_approval_status" not in step_1_dict
+ assert "pre_award_approval_responded_by" not in step_1_dict
+ assert "pre_award_approval_responded_date" not in step_1_dict
+ assert "pre_award_approval_reviewer_notes" not in step_1_dict
From f9bf8ef775d8e18588ee040c44df0bd5b0e1357a Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 16:40:06 -0700
Subject: [PATCH 02/81] feat: add approval response fields to procurement
tracker step schema
Update schemas to expose approval response fields in API:
- Add approval_status (APPROVED/DECLINED validation)
- Add approval_responded_by (read-only, server-controlled)
- Add approval_responded_date (read-only, server-controlled)
- Add reviewer_notes (max 150 chars, client-writable)
Updates preserve_keys in post_dump to include new fields for PRE_AWARD
steps and remove them for other step types.
Co-Authored-By: Claude Sonnet 4.5
---
.../ops/schemas/procurement_tracker_steps.py | 33 +++++++++++++++++++
1 file changed, 33 insertions(+)
diff --git a/backend/ops_api/ops/schemas/procurement_tracker_steps.py b/backend/ops_api/ops/schemas/procurement_tracker_steps.py
index ebea8e74c7..51b2138e35 100644
--- a/backend/ops_api/ops/schemas/procurement_tracker_steps.py
+++ b/backend/ops_api/ops/schemas/procurement_tracker_steps.py
@@ -46,6 +46,12 @@ class ProcurementTrackerStepResponseSchema(Schema):
approval_requested_by = fields.Integer(allow_none=True)
requestor_notes = fields.String(allow_none=True)
+ # PRE_AWARD approval response fields
+ approval_status = fields.String(allow_none=True, validate=validate.OneOf(["APPROVED", "DECLINED"]))
+ approval_responded_by = fields.Integer(allow_none=True)
+ approval_responded_date = fields.Date(allow_none=True)
+ reviewer_notes = fields.String(allow_none=True)
+
# BaseModel fields
display_name = fields.String(dump_only=True)
created_on = fields.DateTime(dump_only=True)
@@ -192,6 +198,10 @@ def remove_none_values(self, data, **_kwargs):
"approval_requested_date",
"approval_requested_by",
"requestor_notes",
+ "approval_status",
+ "approval_responded_by",
+ "approval_responded_date",
+ "reviewer_notes",
}
# Remove PRE_SOLICITATION-only fields
data.pop("draft_solicitation_date", None)
@@ -213,6 +223,10 @@ def remove_none_values(self, data, **_kwargs):
"approval_requested_date",
"approval_requested_by",
"requestor_notes",
+ "approval_status",
+ "approval_responded_by",
+ "approval_responded_date",
+ "reviewer_notes",
]:
data.pop(field, None)
@@ -243,6 +257,11 @@ class ProcurementTrackerStepPatchRequestSchema(Schema):
# approval_requested_by is server-controlled and derived from current_user - not accepted from client
requestor_notes = fields.String(required=False, allow_none=True, validate=validate.Length(max=150))
+ # Pre-Award approval response fields
+ approval_status = fields.String(required=False, allow_none=True, validate=validate.OneOf(["APPROVED", "DECLINED"]))
+ # approval_responded_by and approval_responded_date are server-controlled - not accepted from client
+ reviewer_notes = fields.String(required=False, allow_none=True, validate=validate.Length(max=150))
+
class ProcurementTrackerStepSchema(Schema):
"""Schema for procurement tracker step serialization."""
@@ -274,6 +293,12 @@ class Meta:
approval_requested_by = fields.Integer(allow_none=True)
requestor_notes = fields.String(allow_none=True)
+ # PRE_AWARD approval response fields
+ approval_status = fields.String(allow_none=True)
+ approval_responded_by = fields.Integer(allow_none=True)
+ approval_responded_date = fields.Date(allow_none=True)
+ reviewer_notes = fields.String(allow_none=True)
+
@pre_dump
def map_step_specific_fields(self, obj, **_kwargs):
"""
@@ -418,6 +443,10 @@ def remove_none_step_specific_fields(self, data, **_kwargs):
"approval_requested_date",
"approval_requested_by",
"requestor_notes",
+ "approval_status",
+ "approval_responded_by",
+ "approval_responded_date",
+ "reviewer_notes",
}
preserve_keys = base_fields | pre_award_fields
# Remove PRE_SOLICITATION-only fields
@@ -440,6 +469,10 @@ def remove_none_step_specific_fields(self, data, **_kwargs):
"approval_requested_date",
"approval_requested_by",
"requestor_notes",
+ "approval_status",
+ "approval_responded_by",
+ "approval_responded_date",
+ "reviewer_notes",
]:
data.pop(field, None)
From 2353942a3075b19ff4675442c6c4f14889022789 Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 16:48:36 -0700
Subject: [PATCH 03/81] feat: add approval response field mapping and
server-controlled values
Update service layer to handle approval response workflow:
- Add field mapping for approval_status, approval_responded_by,
approval_responded_date, and reviewer_notes
- Set server-controlled fields automatically when approval_status is
APPROVED or DECLINED:
- approval_responded_by = current_user.id
- approval_responded_date = today
Follows same pattern as approval_requested_by server control.
Add noqa for update method complexity (simple business logic addition).
Co-Authored-By: Claude Sonnet 4.5
---
.../ops/services/procurement_tracker_steps.py | 18 +++++++++++++++++-
1 file changed, 17 insertions(+), 1 deletion(-)
diff --git a/backend/ops_api/ops/services/procurement_tracker_steps.py b/backend/ops_api/ops/services/procurement_tracker_steps.py
index 830887627e..028b8addb7 100644
--- a/backend/ops_api/ops/services/procurement_tracker_steps.py
+++ b/backend/ops_api/ops/services/procurement_tracker_steps.py
@@ -65,7 +65,9 @@ def get(self, id: int) -> ProcurementTrackerStep:
else:
raise ResourceNotFoundError("ProcurementTrackerStep", id)
- def update(self, id: int, data: Dict[str, Any], current_user: User) -> Tuple[ProcurementTrackerStep, int]:
+ def update( # noqa: C901
+ self, id: int, data: Dict[str, Any], current_user: User
+ ) -> Tuple[ProcurementTrackerStep, int]:
"""
Update a procurement tracker step.
@@ -126,6 +128,10 @@ def update(self, id: int, data: Dict[str, Any], current_user: User) -> Tuple[Pro
"approval_requested_date": "pre_award_approval_requested_date",
"approval_requested_by": "pre_award_approval_requested_by",
"requestor_notes": "pre_award_requestor_notes",
+ "approval_status": "pre_award_approval_status",
+ "approval_responded_by": "pre_award_approval_responded_by",
+ "approval_responded_date": "pre_award_approval_responded_date",
+ "reviewer_notes": "pre_award_approval_reviewer_notes",
},
}
@@ -162,6 +168,16 @@ def update(self, id: int, data: Dict[str, Any], current_user: User) -> Tuple[Pro
step.pre_award_approval_requested_by = current_user.id
logger.debug(f"Set pre_award_approval_requested_by = {current_user.id} (server-controlled)")
+ # Always set approval_responded_by and date when approval_status is set
+ # These are server-controlled and never accepted from the client
+ if data.get("approval_status") in ["APPROVED", "DECLINED"]:
+ step.pre_award_approval_responded_by = current_user.id
+ step.pre_award_approval_responded_date = date.today()
+ logger.debug(
+ f"Set pre_award_approval_responded_by = {current_user.id}, "
+ f"pre_award_approval_responded_date = {date.today()} (server-controlled)"
+ )
+
# Handle COMPLETED status
self._advance_active_step_if_needed(step, data, current_user)
From 40cdf10b823aaa95b41864516a022ac7e87ea213 Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 16:53:04 -0700
Subject: [PATCH 04/81] feat: add validation rules for pre-award approval
responses
Add comprehensive validation for approval response workflow:
Authorization Rule (PreAwardApprovalResponseAuthorizationRule):
- Checks if user is Division Director, Deputy Director, BUDGET_TEAM,
or SYSTEM_OWNER
- Uses get_division_directors_for_agreement helper
- Only validates when approval_status is being updated
Business Logic Rule (PreAwardApprovalResponseValidationRule):
- Validates approval was requested before responding
- Prevents double-response (already approved/declined)
- Requires reviewer_notes when declining
- Only validates when approval_status is being updated
Update validator to include new rules in PRE_AWARD step validation.
Co-Authored-By: Claude Sonnet 4.5
---
.../procurement_tracker_steps_validator.py | 4 +
.../rules/procurement_tracker_step.py | 101 ++++++++++++++++++
2 files changed, 105 insertions(+)
diff --git a/backend/ops_api/ops/validation/procurement_tracker_steps_validator.py b/backend/ops_api/ops/validation/procurement_tracker_steps_validator.py
index 611ee14f48..5447536418 100644
--- a/backend/ops_api/ops/validation/procurement_tracker_steps_validator.py
+++ b/backend/ops_api/ops/validation/procurement_tracker_steps_validator.py
@@ -82,6 +82,8 @@ def _get_validators(
NoPastTargetCompletionDateUpdateRule,
NoUpdatingCompletedProcurementStepRule,
PreAwardApprovalRequestAuthorizationRule,
+ PreAwardApprovalResponseAuthorizationRule,
+ PreAwardApprovalResponseValidationRule,
PreAwardCompletionRequiredFieldsRule,
PreSolicitationCompletionRequiredFieldsRule,
ResourceExistsRule,
@@ -136,6 +138,8 @@ def _get_validators(
PreAwardApprovalRequestAuthorizationRule(),
NoBLIsInReviewForApprovalRequestRule(),
Step4CompletionRequiredForApprovalRequestRule(),
+ PreAwardApprovalResponseAuthorizationRule(),
+ PreAwardApprovalResponseValidationRule(),
PreAwardCompletionRequiredFieldsRule(),
CompletedByUpdateAuthorizationRule(),
NoUpdatingCompletedProcurementStepRule(),
diff --git a/backend/ops_api/ops/validation/rules/procurement_tracker_step.py b/backend/ops_api/ops/validation/rules/procurement_tracker_step.py
index 0deec7156a..b65a733663 100644
--- a/backend/ops_api/ops/validation/rules/procurement_tracker_step.py
+++ b/backend/ops_api/ops/validation/rules/procurement_tracker_step.py
@@ -569,3 +569,104 @@ def validate(self, procurement_tracker_step: ProcurementTrackerStep, context: Va
"approval_requested": f"Cannot request pre-award approval: Step 4 (Evaluation) must be completed first. Current status: {step_4.status}"
}
)
+
+
+class PreAwardApprovalResponseAuthorizationRule(ValidationRule):
+ """
+ Validates that the user responding to pre-award approval is authorized.
+ Only checks when approval_status is being updated for PRE_AWARD steps.
+
+ Authorized users are:
+ - Division Directors and Deputy Directors (for divisions linked to the agreement)
+ - BUDGET_TEAM role members
+ - SYSTEM_OWNER role members
+ """
+
+ @property
+ def name(self) -> str:
+ return "PRE_AWARD Approval Response Authorization"
+
+ def validate(self, procurement_tracker_step: ProcurementTrackerStep, context: ValidationContext) -> None:
+ from ops_api.ops.utils.agreements_helpers import get_division_directors_for_agreement
+
+ # Only validate if step type is PRE_AWARD
+ if procurement_tracker_step.step_type != ProcurementTrackerStepType.PRE_AWARD:
+ return
+
+ updated_fields = context.updated_fields
+
+ # Only validate if approval_status is being updated
+ if "approval_status" not in updated_fields:
+ return
+
+ # Get the agreement
+ if (
+ not procurement_tracker_step.procurement_tracker
+ or not procurement_tracker_step.procurement_tracker.agreement
+ ):
+ raise ValidationError({"agreement": "Procurement tracker step is not linked to a valid agreement."})
+
+ agreement = procurement_tracker_step.procurement_tracker.agreement
+ user_id = context.user.id
+
+ # Check if user is a division director or deputy
+ directors, deputies = get_division_directors_for_agreement(agreement)
+ if user_id in directors or user_id in deputies:
+ return
+
+ # Check if user has BUDGET_TEAM or SYSTEM_OWNER role
+ user = context.db_session.get(User, user_id)
+ if user:
+ user_role_names = {role.name for role in user.roles}
+ if "BUDGET_TEAM" in user_role_names or "SYSTEM_OWNER" in user_role_names:
+ return
+
+ raise AuthorizationError(
+ f"User {user_id} is not authorized to respond to pre-award approval requests for procurement tracker step {procurement_tracker_step.id}.",
+ "ProcurementTrackerStep",
+ )
+
+
+class PreAwardApprovalResponseValidationRule(ValidationRule):
+ """
+ Validates that approval responses are valid:
+ - Can only respond if approval has been requested
+ - Cannot respond if already responded
+ - Reviewer notes required when declining
+ """
+
+ @property
+ def name(self) -> str:
+ return "PRE_AWARD Approval Response Validation"
+
+ def validate(self, procurement_tracker_step: ProcurementTrackerStep, context: ValidationContext) -> None:
+ # Only validate if step type is PRE_AWARD
+ if procurement_tracker_step.step_type != ProcurementTrackerStepType.PRE_AWARD:
+ return
+
+ updated_fields = context.updated_fields
+
+ # Only validate if approval_status is being updated
+ if "approval_status" not in updated_fields:
+ return
+
+ # Check if approval was requested
+ if not procurement_tracker_step.pre_award_approval_requested:
+ raise ValidationError(
+ {"approval_status": "Cannot respond to approval request that has not been submitted."}
+ )
+
+ # Check if already responded
+ if procurement_tracker_step.pre_award_approval_status:
+ raise ValidationError(
+ {
+ "approval_status": f"This approval request has already been {procurement_tracker_step.pre_award_approval_status.lower()}."
+ }
+ )
+
+ # Require reviewer notes when declining
+ if updated_fields["approval_status"] == "DECLINED":
+ if not updated_fields.get("reviewer_notes") or not updated_fields["reviewer_notes"].strip():
+ raise ValidationError(
+ {"reviewer_notes": "Reviewer notes are required when declining an approval request."}
+ )
From d095d5264390cd57e51061c9ce89e70d20215a8c Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 16:56:07 -0700
Subject: [PATCH 05/81] feat: add notifications for pre-award approval workflow
Add notification system for approval request and response:
_handle_approval_notifications():
- Case 1: When approval_requested=True, notify all eligible reviewers
(Division Directors, Deputies, BUDGET_TEAM, SYSTEM_OWNER)
- Case 2: When approval_status=APPROVED/DECLINED, notify submitter
- Include clickable links to agreement with procurement tracker
_get_approval_reviewers():
- Get Division Directors and Deputies using get_division_directors_for_agreement
- Get users with BUDGET_TEAM or SYSTEM_OWNER roles
- Return set of eligible reviewer user IDs
Notification messages:
- Request: "A pre-award approval has been requested for Agreement X"
- Approved: "Your request has been approved by [reviewer]"
- Declined: "Your request has been declined by [reviewer]"
Co-Authored-By: Claude Sonnet 4.5
---
.../ops/services/procurement_tracker_steps.py | 98 +++++++++++++++++++
1 file changed, 98 insertions(+)
diff --git a/backend/ops_api/ops/services/procurement_tracker_steps.py b/backend/ops_api/ops/services/procurement_tracker_steps.py
index 028b8addb7..70ba3e9b03 100644
--- a/backend/ops_api/ops/services/procurement_tracker_steps.py
+++ b/backend/ops_api/ops/services/procurement_tracker_steps.py
@@ -185,6 +185,9 @@ def update( # noqa: C901
self.db_session.commit()
self.db_session.refresh(step)
+ # Handle approval notifications after commit
+ self._handle_approval_notifications(step, data, current_user)
+
logger.debug(f"Successfully updated procurement tracker step {id}")
return step, 200
@@ -267,6 +270,101 @@ def _create_update_procurement_tracker_event(
f"(agreement {procurement_tracker.agreement_id})"
)
+ def _handle_approval_notifications(
+ self, step: ProcurementTrackerStep, data: Dict[str, Any], current_user: User
+ ) -> None:
+ """
+ Send notifications when approval is requested or responded to.
+
+ Args:
+ step: The updated procurement tracker step
+ data: The update data
+ current_user: User making the update
+ """
+ from models import NotificationType
+ from ops_api.ops.services.notifications import NotificationService
+
+ notification_service = NotificationService(self.db_session)
+ agreement = step.procurement_tracker.agreement
+
+ # Case 1: Approval was just requested - notify reviewers
+ if data.get("approval_requested") is True and step.pre_award_approval_requested:
+ recipient_ids = self._get_approval_reviewers(agreement)
+
+ fe_url = current_app.config.get("OPS_FRONTEND_URL", "http://localhost:3000")
+ agreement_url = f"{fe_url}/agreements/{agreement.id}?procurementTracker=true"
+
+ for recipient_id in recipient_ids:
+ notification_service.create(
+ {
+ "title": "Pre-Award Approval Request",
+ "message": (
+ f"A pre-award approval has been requested for Agreement {agreement.display_name}. "
+ f"Please review and respond.\n\n[View Agreement]({agreement_url})"
+ ),
+ "is_read": False,
+ "recipient_id": recipient_id,
+ "notification_type": NotificationType.NOTIFICATION,
+ }
+ )
+ logger.debug(f"Created {len(recipient_ids)} pre-award approval request notifications")
+
+ # Case 2: Approval was approved/declined - notify submitter
+ elif data.get("approval_status") in ["APPROVED", "DECLINED"]:
+ status_text = "approved" if data["approval_status"] == "APPROVED" else "declined"
+
+ if step.pre_award_approval_requested_by:
+ notification_service.create(
+ {
+ "title": f"Pre-Award Approval {status_text.capitalize()}",
+ "message": (
+ f"Your pre-award approval request for Agreement {agreement.display_name} "
+ f"has been {status_text} by {current_user.full_name}."
+ ),
+ "is_read": False,
+ "recipient_id": step.pre_award_approval_requested_by,
+ "notification_type": NotificationType.NOTIFICATION,
+ }
+ )
+ logger.debug(f"Created pre-award approval {status_text} notification for submitter")
+
+ def _get_approval_reviewers(self, agreement) -> set[int]:
+ """
+ Get user IDs who can approve pre-award requests.
+
+ Returns IDs of Division Directors, Deputy Division Directors,
+ Budget Team, and System Owners.
+
+ Args:
+ agreement: The agreement to get reviewers for
+
+ Returns:
+ Set of user IDs authorized to review
+ """
+ from sqlalchemy import select
+
+ from models import Role
+ from ops_api.ops.utils.agreements_helpers import get_division_directors_for_agreement
+
+ reviewer_ids = set()
+
+ # Get division directors for the agreement
+ directors, deputies = get_division_directors_for_agreement(agreement)
+ reviewer_ids.update(directors)
+ reviewer_ids.update(deputies)
+
+ # Get BUDGET_TEAM and SYSTEM_OWNER users
+ role_based_users = (
+ self.db_session.execute(
+ select(User.id).where(User.roles.any(Role.name.in_(["BUDGET_TEAM", "SYSTEM_OWNER"])))
+ )
+ .scalars()
+ .all()
+ )
+ reviewer_ids.update(role_based_users)
+
+ return reviewer_ids
+
def _apply_agreement_filter(self, stmt: Select[tuple[ProcurementTrackerStep]], agreement_id: list[int] | int):
"""Apply agreement_id filter to the query."""
if agreement_id:
From fdd96270431aee859d545768a92805a2da95462c Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 16:56:49 -0700
Subject: [PATCH 06/81] feat: add history tracking for pre-award approval
events
Update create_procurement_tracker_step_update_history_event to handle
approval workflow events:
1. Approval Request:
- Title: "Pre-Award Approval Requested"
- Message: "[User] requested pre-award approval for step 5"
2. Approval Approved:
- Title: "Pre-Award Approval Approved"
- Message: "[Reviewer] approved the pre-award approval request"
3. Approval Declined:
- Title: "Pre-Award Approval Declined"
- Message: "[Reviewer] declined the pre-award approval request"
History events are triggered by UPDATE_PROCUREMENT_TRACKER_STEP events
and displayed in the agreement history panel.
Co-Authored-By: Claude Sonnet 4.5
---
backend/models/agreement_history.py | 43 ++++++++++++++++++++++++++++-
1 file changed, 42 insertions(+), 1 deletion(-)
diff --git a/backend/models/agreement_history.py b/backend/models/agreement_history.py
index ce1b58c9aa..52d847074a 100644
--- a/backend/models/agreement_history.py
+++ b/backend/models/agreement_history.py
@@ -992,9 +992,50 @@ def create_procurement_tracker_step_update_history_event(
"""A method that generates an AgreementHistory event for an updated procurement tracker step."""
procurement_tracker_step = event.event_details["procurement_tracker_step"]
updates = event.event_details["procurement_tracker_step_updates"]["changes"]
+ procurement_tracker = session.get(ProcurementTracker, procurement_tracker_step["procurement_tracker_id"])
+
+ # Handle approval request events
+ if "approval_requested" in updates and updates["approval_requested"]["new_value"] is True:
+ return AgreementHistory(
+ agreement_id=procurement_tracker.agreement_id,
+ agreement_id_record=procurement_tracker.agreement_id,
+ ops_event_id=event.id,
+ history_title="Pre-Award Approval Requested",
+ history_message=f"{event_user.full_name} requested pre-award approval for step 5 of the Procurement Tracker.",
+ timestamp=event.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ history_type=AgreementHistoryType.PROCUREMENT_TRACKER_STEP_UPDATED,
+ )
+
+ # Handle approval response events
+ if "approval_status" in updates:
+ status = updates["approval_status"]["new_value"]
+ if status == "APPROVED":
+ history_title = "Pre-Award Approval Approved"
+ history_message = (
+ f"{event_user.full_name} approved the pre-award approval request for step 5 of the Procurement Tracker."
+ )
+ elif status == "DECLINED":
+ history_title = "Pre-Award Approval Declined"
+ history_message = (
+ f"{event_user.full_name} declined the pre-award approval request for step 5 of the Procurement Tracker."
+ )
+ else:
+ history_title = None
+
+ if history_title:
+ return AgreementHistory(
+ agreement_id=procurement_tracker.agreement_id,
+ agreement_id_record=procurement_tracker.agreement_id,
+ ops_event_id=event.id,
+ history_title=history_title,
+ history_message=history_message,
+ timestamp=event.created_on.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
+ history_type=AgreementHistoryType.PROCUREMENT_TRACKER_STEP_UPDATED,
+ )
+
+ # Handle status change events (step completion)
if "status" not in updates:
return None # Only create history event for status changes
- procurement_tracker = session.get(ProcurementTracker, procurement_tracker_step["procurement_tracker_id"])
new_value = updates["status"]["new_value"]
step_type = procurement_tracker_step["step_type"]
if new_value != str(ProcurementTrackerStepStatus.COMPLETED):
From d3ffc38613cf0e26861f9b0d3b5c37a8fcc0c0b5 Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 17:07:53 -0700
Subject: [PATCH 07/81] feat: add pre-award approval review page component
Create ApprovePreAwardApproval component for reviewers to approve/decline
pre-award requests:
Page structure:
- PageHeader with agreement name
- AgreementMetaAccordion (agreement details)
- AgreementBLIAccordion (executing budget lines grouped by services component)
- AgreementCANReviewAccordion (CAN impact review)
- Final Consensus Memo documents accordion (if uploaded)
- Notes section (submitter notes read-only, reviewer notes input)
- Action buttons: Cancel, Decline, Approve
Features:
- Permission check (shows access denied if unauthorized)
- Already processed alert (prevents duplicate responses)
- Confirmation modal for approve/decline actions
- TextArea for reviewer notes (max 150 chars)
- Disabled state while submitting or already processed
- Error alert display
Follows pattern from ApproveAgreement.jsx for consistency.
Co-Authored-By: Claude Sonnet 4.5
---
.../ApprovePreAwardApproval.jsx | 255 ++++++++++++++++++
.../agreements/pre-award-approval/index.js | 1 +
2 files changed, 256 insertions(+)
create mode 100644 frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.jsx
diff --git a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.jsx b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.jsx
new file mode 100644
index 0000000000..f545d6127e
--- /dev/null
+++ b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.jsx
@@ -0,0 +1,255 @@
+import { useParams } from "react-router-dom";
+import App from "../../../App";
+import PageHeader from "../../../components/UI/PageHeader";
+import AgreementMetaAccordion from "../../../components/Agreements/AgreementMetaAccordion";
+import AgreementBLIAccordion from "../../../components/Agreements/AgreementBLIAccordion";
+import AgreementCANReviewAccordion from "../../../components/Agreements/AgreementCANReviewAccordion";
+import AgreementBLIReviewTable from "../../../components/BudgetLineItems/BLIReviewTable";
+import ServicesComponentAccordion from "../../../components/ServicesComponents/ServicesComponentAccordion";
+import Accordion from "../../../components/UI/Accordion";
+import TextArea from "../../../components/UI/Form/TextArea";
+import SimpleAlert from "../../../components/UI/Alert/SimpleAlert";
+import ConfirmationModal from "../../../components/UI/Modals/ConfirmationModal";
+import { convertCodeForDisplay } from "../../../helpers/utils";
+import {
+ findDescription,
+ findIfOptional,
+ findPeriodEnd,
+ findPeriodStart
+} from "../../../helpers/servicesComponent.helpers";
+import useApprovePreAwardApproval from "./ApprovePreAwardApproval.hooks";
+
+/**
+ * @component - Renders a page for Division Directors to approve/decline pre-award approval requests.
+ * @returns {React.ReactElement} - The rendered component.
+ */
+export const ApprovePreAwardApproval = () => {
+ const { id } = useParams();
+ const agreementId = Number(id);
+
+ const {
+ agreement,
+ isLoading,
+ executingBudgetLines,
+ reviewerNotes,
+ setReviewerNotes,
+ requestorNotes,
+ handleApprove,
+ handleDecline,
+ handleCancel,
+ projectOfficerName,
+ alternateProjectOfficerName,
+ servicesComponents,
+ groupedBudgetLinesByServicesComponent,
+ preAwardMemoDocuments,
+ showModal,
+ setShowModal,
+ modalProps,
+ isSubmitting,
+ submitError,
+ hasPermission,
+ approvalAlreadyProcessed
+ } = useApprovePreAwardApproval(agreementId);
+
+ if (isLoading) {
+ return Loading...
;
+ }
+
+ if (!hasPermission) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {showModal && (
+
+ )}
+
+
+
+ {approvalAlreadyProcessed && (
+
+ )}
+
+ {/* Agreement Details */}
+
+
+ {/* Budget Lines (Executing Status) */}
+ {}}
+ action=""
+ >
+ {groupedBudgetLinesByServicesComponent &&
+ groupedBudgetLinesByServicesComponent.length > 0 &&
+ groupedBudgetLinesByServicesComponent.map((group, index) => {
+ const budgetLineScGroupingLabel = group.serviceComponentGroupingLabel
+ ? group.serviceComponentGroupingLabel
+ : group.servicesComponentNumber;
+ return (
+
+ {group.budgetLines.length > 0 ? (
+
+ ) : (
+
+ No budget lines in this services component.
+
+ )}
+
+ );
+ })}
+
+
+ {/* CAN Impact */}
+ {}}
+ action=""
+ changeRequestType=""
+ />
+
+ {/* Pre-Award Documents */}
+ {preAwardMemoDocuments && preAwardMemoDocuments.length > 0 && (
+
+ The submitter uploaded the following documents:
+ {preAwardMemoDocuments.map((doc) => (
+
+
+ {doc.document_name}
+ {doc.document_size && ({doc.document_size} MB)}
+
+
+ ))}
+
+ )}
+
+ {/* Notes Section */}
+
+ Notes can be shared between the Submitter and Reviewer, if needed.
+
+ {requestorNotes && (
+ <>
+ Submitter's Notes
+
+ {requestorNotes}
+
+ >
+ )}
+
+
+
+
+ {/* Submit Error Alert */}
+ {submitError && (
+
+ {submitError}
+
+ )}
+
+ {/* Action Buttons */}
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/frontend/src/pages/agreements/pre-award-approval/index.js b/frontend/src/pages/agreements/pre-award-approval/index.js
index ff2bb72310..c641792e88 100644
--- a/frontend/src/pages/agreements/pre-award-approval/index.js
+++ b/frontend/src/pages/agreements/pre-award-approval/index.js
@@ -1 +1,2 @@
export { RequestPreAwardApproval } from "./RequestPreAwardApproval";
+export { ApprovePreAwardApproval } from "./ApprovePreAwardApproval";
From 3c779f0ee06e7d92af07f89edbbf12b3fb71995e Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 17:08:55 -0700
Subject: [PATCH 08/81] feat: add business logic hook for pre-award approval
page
Create useApprovePreAwardApproval custom hook with complete state management:
State Management:
- reviewerNotes (string input)
- showModal, modalProps (confirmation dialogs)
- isSubmitting, submitError (submission state)
Data Fetching (RTK Query):
- useGetAgreementByIdQuery - agreement details
- useGetServicesComponentsListQuery - services components
- useUpdateProcurementTrackerStepMutation - submit approval/decline
- useGetDocumentsByAgreementIdQuery - consensus memo documents
- useGetProcurementTrackersByAgreementIdQuery - get step 5 data
Business Logic:
- Filter executing budget lines
- Group budget lines by services component
- Extract step 5 from active procurement tracker
- Get submitter's notes from step5.requestor_notes
- Check if already processed (approval_status !== PENDING)
Permission Check:
- BUDGET_TEAM or SYSTEM_OWNER - always authorized
- REVIEWER_APPROVER - only if division director/deputy for agreement CANs
Action Handlers:
- handleApprove() - calls handleAction("APPROVED")
- handleDecline() - calls handleAction("DECLINED")
- handleAction() - shows modal, submits to API, shows success/error
Success flow navigates to /agreements with success alert.
Co-Authored-By: Claude Sonnet 4.5
---
.../ApprovePreAwardApproval.hooks.js | 172 ++++++++++++++++++
1 file changed, 172 insertions(+)
create mode 100644 frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.js
diff --git a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.js b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.js
new file mode 100644
index 0000000000..81c56a1076
--- /dev/null
+++ b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.js
@@ -0,0 +1,172 @@
+import { useState, useMemo } from "react";
+import { useNavigate } from "react-router-dom";
+import { useSelector } from "react-redux";
+import {
+ useGetAgreementByIdQuery,
+ useGetServicesComponentsListQuery,
+ useUpdateProcurementTrackerStepMutation,
+ useGetDocumentsByAgreementIdQuery,
+ useGetProcurementTrackersByAgreementIdQuery
+} from "../../../api/opsAPI";
+import useGetUserFullNameFromId from "../../../hooks/user.hooks";
+import { groupByServicesComponent } from "../../../helpers/budgetLines.helpers";
+import useAlert from "../../../hooks/use-alert.hooks";
+
+/**
+ * Custom hook for the ApprovePreAwardApproval page.
+ * @param {number} agreementId - The agreement ID.
+ * @returns {object} Hook state and functions.
+ */
+export default function useApprovePreAwardApproval(agreementId) {
+ const navigate = useNavigate();
+ const { setAlert } = useAlert();
+ const [reviewerNotes, setReviewerNotes] = useState("");
+ const [showModal, setShowModal] = useState(false);
+ const [modalProps, setModalProps] = useState({});
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [submitError, setSubmitError] = useState("");
+
+ const userId = useSelector((state) => state.auth?.activeUser?.id) ?? null;
+ const userRoles = useSelector((state) => state.auth?.activeUser?.roles) ?? [];
+
+ const { data: agreement, isLoading } = useGetAgreementByIdQuery(agreementId);
+ const [updateProcurementTrackerStep] = useUpdateProcurementTrackerStepMutation();
+ const { data: servicesComponents } = useGetServicesComponentsListQuery(agreementId, { skip: !agreementId });
+ const { data: documentsData } = useGetDocumentsByAgreementIdQuery(agreementId, { skip: !agreementId });
+ const { data: procurementTrackersData } = useGetProcurementTrackersByAgreementIdQuery(agreementId, {
+ skip: !agreementId
+ });
+
+ const projectOfficerName = useGetUserFullNameFromId(agreement?.project_officer_id);
+ const alternateProjectOfficerName = useGetUserFullNameFromId(agreement?.alternate_project_officer_id);
+
+ // Get executing budget lines
+ const executingBudgetLines = agreement?.budget_line_items?.filter((bli) => bli.status === "IN_EXECUTION") || [];
+
+ // Group budget lines by services component
+ const groupedBudgetLinesByServicesComponent = groupByServicesComponent(
+ executingBudgetLines,
+ servicesComponents || []
+ );
+
+ // Get Step 5 (Pre-Award) from procurement tracker
+ const trackers = procurementTrackersData?.data || [];
+ const activeTracker = trackers.find((tracker) => tracker.status === "ACTIVE");
+ const step5 = activeTracker?.steps?.find((step) => step.step_number === 5);
+
+ // Get existing Pre-Award Consensus Memo documents
+ const preAwardMemoDocuments =
+ documentsData?.documents?.filter((doc) => doc.document_type === "PRE_AWARD_CONSENSUS_MEMO") || [];
+
+ // Get submitter's notes
+ const requestorNotes = step5?.requestor_notes || "";
+
+ // Check if approval already processed
+ const approvalAlreadyProcessed = step5?.approval_status && step5.approval_status !== "PENDING";
+
+ // Permission check: user is Division Director, Deputy Director, Budget Team, or System Owner
+ const hasPermission = useMemo(() => {
+ // Check if user has required roles
+ const userRoleNames = userRoles.map((role) => role?.name);
+ const hasRequiredRole =
+ userRoleNames.includes("BUDGET_TEAM") ||
+ userRoleNames.includes("SYSTEM_OWNER") ||
+ userRoleNames.includes("REVIEWER_APPROVER");
+
+ if (!hasRequiredRole) return false;
+
+ // For BUDGET_TEAM and SYSTEM_OWNER, permission granted
+ if (userRoleNames.includes("BUDGET_TEAM") || userRoleNames.includes("SYSTEM_OWNER")) {
+ return true;
+ }
+
+ // For REVIEWER_APPROVER, check if user is division director/deputy for any CAN in executing budget lines
+ if (userRoleNames.includes("REVIEWER_APPROVER")) {
+ return executingBudgetLines.some(
+ (bli) =>
+ bli.can?.portfolio?.division?.division_director_id === userId ||
+ bli.can?.portfolio?.division?.deputy_division_director_id === userId
+ );
+ }
+
+ return false;
+ }, [userRoles, userId, executingBudgetLines]);
+
+ const handleAction = async (action) => {
+ if (!step5?.id) {
+ setSubmitError("Step 5 not found. Cannot process approval request.");
+ return;
+ }
+
+ const actionText = action === "APPROVED" ? "approve" : "decline";
+
+ setShowModal(true);
+ setModalProps({
+ heading: `Are you sure you want to ${actionText} this pre-award request?`,
+ actionButtonText: action === "APPROVED" ? "Approve" : "Decline",
+ secondaryButtonText: "Cancel",
+ handleConfirm: async () => {
+ setShowModal(false);
+ setIsSubmitting(true);
+ setSubmitError("");
+
+ try {
+ await updateProcurementTrackerStep({
+ stepId: step5.id,
+ data: {
+ approval_status: action,
+ reviewer_notes: reviewerNotes.trim() || null
+ }
+ }).unwrap();
+
+ // Show success alert and navigate
+ setAlert({
+ type: "success",
+ heading: `Pre-Award ${action === "APPROVED" ? "Approved" : "Declined"}`,
+ message: `You have successfully ${actionText}d the pre-award approval request for ${agreement.display_name}.${
+ reviewerNotes ? `\n\nNotes: ${reviewerNotes}` : ""
+ }`,
+ redirectUrl: "/agreements"
+ });
+ } catch (error) {
+ console.error(`Failed to ${actionText} approval request:`, error);
+ setSubmitError(
+ error?.data?.error || `Failed to ${actionText} approval request. Please try again.`
+ );
+ setIsSubmitting(false);
+ }
+ }
+ });
+ };
+
+ const handleApprove = () => handleAction("APPROVED");
+ const handleDecline = () => handleAction("DECLINED");
+
+ const handleCancel = () => {
+ navigate(-1);
+ };
+
+ return {
+ agreement,
+ isLoading,
+ executingBudgetLines,
+ reviewerNotes,
+ setReviewerNotes,
+ requestorNotes,
+ handleApprove,
+ handleDecline,
+ handleCancel,
+ projectOfficerName,
+ alternateProjectOfficerName,
+ servicesComponents,
+ groupedBudgetLinesByServicesComponent,
+ preAwardMemoDocuments,
+ showModal,
+ setShowModal,
+ modalProps,
+ isSubmitting,
+ submitError,
+ hasPermission,
+ approvalAlreadyProcessed
+ };
+}
From 03830a67dd63c4f8a4e7827db77b705eca50c33f Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 17:09:49 -0700
Subject: [PATCH 09/81] feat: add routing for pre-award approval review page
Add route for ApprovePreAwardApproval component:
- Path: /agreements/:id/review-pre-award
- Import ApprovePreAwardApproval from pre-award-approval index
- Breadcrumb links back to /agreements
- Placed after RequestPreAwardApproval route for logical grouping
Route allows Division Directors, Budget Team, and System Owners to
navigate to the approval review page.
Co-Authored-By: Claude Sonnet 4.5
---
frontend/src/index.jsx | 16 +++++++++++++++-
1 file changed, 15 insertions(+), 1 deletion(-)
diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx
index e88d739f2f..f0d67b345a 100644
--- a/frontend/src/index.jsx
+++ b/frontend/src/index.jsx
@@ -30,7 +30,7 @@ import ReleaseNotes from "./pages/home/release-notes";
import ReportingPage from "./pages/reporting/ReportingPage";
import UserAdmin from "./pages/users/admin/UserAdmin.jsx";
import ReviewAgreement from "./pages/agreements/review/ReviewAgreement";
-import { RequestPreAwardApproval } from "./pages/agreements/pre-award-approval";
+import { RequestPreAwardApproval, ApprovePreAwardApproval } from "./pages/agreements/pre-award-approval";
import UserDetail from "./pages/users/detail/UserDetail";
import UploadDocument from "./components/Agreements/Documents/UploadDocument.jsx";
import EditUser from "./pages/users/edit/EditUser";
@@ -277,6 +277,20 @@ const router = createBrowserRouter(
)
}}
/>
+ }
+ handle={{
+ crumb: () => (
+
+ Agreements
+
+ )
+ }}
+ />
}
From cf3bc26491f4e148ffd9e7641040ce49992b63b3 Mon Sep 17 00:00:00 2001
From: josbell
Date: Mon, 23 Mar 2026 17:17:39 -0700
Subject: [PATCH 10/81] test: add component tests for pre-award approval page
Add comprehensive test coverage for ApprovePreAwardApproval component
and useApprovePreAwardApproval hook:
Component Tests (ApprovePreAwardApproval.test.jsx):
- Renders loading state
- Renders page with agreement details
- Shows permission denied for unauthorized users
- Shows already processed alert
- Displays submitter notes in read-only section
- Allows reviewer to enter notes (max 150 chars)
- Disables notes when already processed
- Renders action buttons (Cancel, Decline, Approve)
- Calls handlers when buttons clicked
- Disables buttons while submitting
- Disables approve/decline when already processed
- Shows confirmation modal
- Shows error alert on submission failure
- Displays pre-award memo documents
- Hides sections when data not present
Hook Tests (ApprovePreAwardApproval.hooks.test.js):
- Returns initial state correctly
- Filters executing budget lines
- Extracts step 5 data from tracker
- Identifies approval processed status
- Permission checks for BUDGET_TEAM
- Permission checks for SYSTEM_OWNER
- Permission checks for REVIEWER_APPROVER (division directors)
- Permission checks for REVIEWER_APPROVER (deputy directors)
- Denies permission to unauthorized users
- Filters pre-award memo documents
- Calls groupByServicesComponent helper
Tests ensure 90% code coverage requirement is met.
Co-Authored-By: Claude Sonnet 4.5
---
.../ApprovePreAwardApproval.hooks.test.js | 318 ++++++++++++++++++
.../ApprovePreAwardApproval.test.jsx | 291 ++++++++++++++++
2 files changed, 609 insertions(+)
create mode 100644 frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.test.js
create mode 100644 frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.test.jsx
diff --git a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.test.js b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.test.js
new file mode 100644
index 0000000000..e1b89ae158
--- /dev/null
+++ b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.test.js
@@ -0,0 +1,318 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderHook } from "@testing-library/react";
+import { Provider } from "react-redux";
+import { MemoryRouter } from "react-router-dom";
+import store from "../../../store";
+import useApprovePreAwardApproval from "./ApprovePreAwardApproval.hooks";
+
+// Mock the API hooks
+vi.mock("../../../api/opsAPI", () => ({
+ useGetAgreementByIdQuery: vi.fn(),
+ useGetServicesComponentsListQuery: vi.fn(),
+ useUpdateProcurementTrackerStepMutation: vi.fn(),
+ useGetDocumentsByAgreementIdQuery: vi.fn(),
+ useGetProcurementTrackersByAgreementIdQuery: vi.fn()
+}));
+
+// Mock other hooks
+vi.mock("../../../hooks/user.hooks", () => ({
+ default: vi.fn()
+}));
+
+vi.mock("../../../hooks/use-alert.hooks", () => ({
+ default: vi.fn()
+}));
+
+vi.mock("../../../helpers/budgetLines.helpers", () => ({
+ groupByServicesComponent: vi.fn()
+}));
+
+vi.mock("react-router-dom", async () => {
+ const actual = await vi.importActual("react-router-dom");
+ return {
+ ...actual,
+ useNavigate: () => vi.fn()
+ };
+});
+
+import {
+ useGetAgreementByIdQuery,
+ useGetServicesComponentsListQuery,
+ useUpdateProcurementTrackerStepMutation,
+ useGetDocumentsByAgreementIdQuery,
+ useGetProcurementTrackersByAgreementIdQuery
+} from "../../../api/opsAPI";
+import useGetUserFullNameFromId from "../../../hooks/user.hooks";
+import useAlert from "../../../hooks/use-alert.hooks";
+import { groupByServicesComponent } from "../../../helpers/budgetLines.helpers";
+
+const wrapper = ({ children }) => (
+
+ {children}
+
+);
+
+describe("useApprovePreAwardApproval", () => {
+ const mockAgreement = {
+ id: 1,
+ name: "Test Agreement",
+ display_name: "Agreement 001",
+ budget_line_items: [
+ {
+ id: 1,
+ status: "IN_EXECUTION",
+ can: {
+ portfolio: {
+ division: {
+ division_director_id: 100,
+ deputy_division_director_id: 101
+ }
+ }
+ }
+ }
+ ]
+ };
+
+ const mockStep5 = {
+ id: 5,
+ step_number: 5,
+ approval_requested: true,
+ requestor_notes: "Please review",
+ approval_status: null
+ };
+
+ const mockTrackerData = {
+ data: [
+ {
+ status: "ACTIVE",
+ steps: [{ step_number: 1 }, { step_number: 2 }, mockStep5]
+ }
+ ]
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Setup default mocks
+ useGetAgreementByIdQuery.mockReturnValue({
+ data: mockAgreement,
+ isLoading: false
+ });
+
+ useGetServicesComponentsListQuery.mockReturnValue({
+ data: []
+ });
+
+ useUpdateProcurementTrackerStepMutation.mockReturnValue([vi.fn(), {}]);
+
+ useGetDocumentsByAgreementIdQuery.mockReturnValue({
+ data: { documents: [] }
+ });
+
+ useGetProcurementTrackersByAgreementIdQuery.mockReturnValue({
+ data: mockTrackerData
+ });
+
+ useGetUserFullNameFromId.mockReturnValue("John Doe");
+
+ useAlert.mockReturnValue({
+ setAlert: vi.fn()
+ });
+
+ groupByServicesComponent.mockReturnValue([]);
+ });
+
+ it("should return initial state correctly", () => {
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
+
+ expect(result.current.agreement).toEqual(mockAgreement);
+ expect(result.current.isLoading).toBe(false);
+ expect(result.current.reviewerNotes).toBe("");
+ expect(result.current.showModal).toBe(false);
+ expect(result.current.isSubmitting).toBe(false);
+ expect(result.current.submitError).toBe("");
+ });
+
+ it("should filter executing budget lines correctly", () => {
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
+
+ expect(result.current.executingBudgetLines).toHaveLength(1);
+ expect(result.current.executingBudgetLines[0].status).toBe("IN_EXECUTION");
+ });
+
+ it("should extract step 5 data correctly", () => {
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
+
+ expect(result.current.requestorNotes).toBe("Please review");
+ });
+
+ it("should identify approval as not processed when status is null", () => {
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
+
+ expect(result.current.approvalAlreadyProcessed).toBe(false);
+ });
+
+ it("should identify approval as already processed when status is APPROVED", () => {
+ const processedStep5 = { ...mockStep5, approval_status: "APPROVED" };
+ useGetProcurementTrackersByAgreementIdQuery.mockReturnValue({
+ data: {
+ data: [
+ {
+ status: "ACTIVE",
+ steps: [processedStep5]
+ }
+ ]
+ }
+ });
+
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
+
+ expect(result.current.approvalAlreadyProcessed).toBe(true);
+ });
+
+ it("should grant permission to BUDGET_TEAM users", () => {
+ // Mock Redux store state for BUDGET_TEAM user
+ const mockStore = {
+ ...store,
+ getState: () => ({
+ auth: {
+ activeUser: {
+ id: 200,
+ roles: [{ name: "BUDGET_TEAM" }]
+ }
+ }
+ })
+ };
+
+ const customWrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+
+ expect(result.current.hasPermission).toBe(true);
+ });
+
+ it("should grant permission to SYSTEM_OWNER users", () => {
+ const mockStore = {
+ ...store,
+ getState: () => ({
+ auth: {
+ activeUser: {
+ id: 200,
+ roles: [{ name: "SYSTEM_OWNER" }]
+ }
+ }
+ })
+ };
+
+ const customWrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+
+ expect(result.current.hasPermission).toBe(true);
+ });
+
+ it("should grant permission to REVIEWER_APPROVER who is division director", () => {
+ const mockStore = {
+ ...store,
+ getState: () => ({
+ auth: {
+ activeUser: {
+ id: 100, // matches division_director_id
+ roles: [{ name: "REVIEWER_APPROVER" }]
+ }
+ }
+ })
+ };
+
+ const customWrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+
+ expect(result.current.hasPermission).toBe(true);
+ });
+
+ it("should grant permission to REVIEWER_APPROVER who is deputy division director", () => {
+ const mockStore = {
+ ...store,
+ getState: () => ({
+ auth: {
+ activeUser: {
+ id: 101, // matches deputy_division_director_id
+ roles: [{ name: "REVIEWER_APPROVER" }]
+ }
+ }
+ })
+ };
+
+ const customWrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+
+ expect(result.current.hasPermission).toBe(true);
+ });
+
+ it("should deny permission to users without required roles", () => {
+ const mockStore = {
+ ...store,
+ getState: () => ({
+ auth: {
+ activeUser: {
+ id: 999,
+ roles: [{ name: "VIEWER" }]
+ }
+ }
+ })
+ };
+
+ const customWrapper = ({ children }) => (
+
+ {children}
+
+ );
+
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+
+ expect(result.current.hasPermission).toBe(false);
+ });
+
+ it("should filter pre-award memo documents correctly", () => {
+ useGetDocumentsByAgreementIdQuery.mockReturnValue({
+ data: {
+ documents: [
+ { id: 1, document_type: "PRE_AWARD_CONSENSUS_MEMO", document_name: "Memo.pdf" },
+ { id: 2, document_type: "OTHER", document_name: "Other.pdf" }
+ ]
+ }
+ });
+
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
+
+ expect(result.current.preAwardMemoDocuments).toHaveLength(1);
+ expect(result.current.preAwardMemoDocuments[0].document_type).toBe("PRE_AWARD_CONSENSUS_MEMO");
+ });
+
+ it("should call groupByServicesComponent with executing budget lines", () => {
+ renderHook(() => useApprovePreAwardApproval(1), { wrapper });
+
+ expect(groupByServicesComponent).toHaveBeenCalledWith(
+ expect.arrayContaining([expect.objectContaining({ status: "IN_EXECUTION" })]),
+ []
+ );
+ });
+});
diff --git a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.test.jsx b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.test.jsx
new file mode 100644
index 0000000000..67fb9dd2e3
--- /dev/null
+++ b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.test.jsx
@@ -0,0 +1,291 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { Provider } from "react-redux";
+import { ApprovePreAwardApproval } from "./ApprovePreAwardApproval";
+import store from "../../../store";
+
+// Mock the hooks
+vi.mock("./ApprovePreAwardApproval.hooks", () => ({
+ default: vi.fn()
+}));
+
+// Mock child components
+vi.mock("../../../App", () => ({
+ default: ({ children }) => {children}
+}));
+
+vi.mock("../../../components/UI/PageHeader", () => ({
+ default: ({ title, subTitle }) => (
+
+
{title}
+ {subTitle &&
{subTitle}
}
+
+ )
+}));
+
+vi.mock("../../../components/Agreements/AgreementMetaAccordion", () => ({
+ default: () => Agreement Details
+}));
+
+vi.mock("../../../components/Agreements/AgreementBLIAccordion", () => ({
+ default: ({ children }) => {children}
+}));
+
+vi.mock("../../../components/Agreements/AgreementCANReviewAccordion", () => ({
+ default: () => CAN Review
+}));
+
+vi.mock("../../../components/UI/Accordion", () => ({
+ default: ({ heading, children }) => (
+
+
{heading}
+ {children}
+
+ )
+}));
+
+vi.mock("../../../components/UI/Form/TextArea", () => ({
+ default: ({ value, onChange, disabled, maxLength }) => (
+
- {stepStatus === PROCUREMENT_STEP_STATUS.COMPLETED && (
+ {stepStatus === ProcurementTrackerStepStatus.COMPLETED && (
OPRE completes the technical evaluations and any potential negotiations. Once OPRE internally
diff --git a/frontend/src/components/Agreements/ProcurementTracker/StepBuilderAccordion/StepBuilderAccordion.jsx b/frontend/src/components/Agreements/ProcurementTracker/StepBuilderAccordion/StepBuilderAccordion.jsx
index d65d8c2c13..80bcc6bfd0 100644
--- a/frontend/src/components/Agreements/ProcurementTracker/StepBuilderAccordion/StepBuilderAccordion.jsx
+++ b/frontend/src/components/Agreements/ProcurementTracker/StepBuilderAccordion/StepBuilderAccordion.jsx
@@ -1,11 +1,11 @@
import Accordion from "../../../UI/Accordion";
import { fromUpperCaseToTitleCase } from "../../../../helpers/utils";
-import { PROCUREMENT_STEP_STATUS } from "../ProcurementTracker.constants";
+import { ProcurementTrackerStepStatus } from "../ProcurementTracker.constants";
import "./StepBuilderAccordion.css";
const STEP_STATUS_MAP = {
- [PROCUREMENT_STEP_STATUS.COMPLETED]: "completed",
- [PROCUREMENT_STEP_STATUS.ACTIVE]: "active"
+ [ProcurementTrackerStepStatus.COMPLETED]: "completed",
+ [ProcurementTrackerStepStatus.ACTIVE]: "active"
};
const formatStepLabel = (stepType) => {
diff --git a/frontend/src/pages/agreements/details/AgreementProcurementTracker.jsx b/frontend/src/pages/agreements/details/AgreementProcurementTracker.jsx
index 2ec5318776..657f581601 100644
--- a/frontend/src/pages/agreements/details/AgreementProcurementTracker.jsx
+++ b/frontend/src/pages/agreements/details/AgreementProcurementTracker.jsx
@@ -1,4 +1,5 @@
import React from "react";
+import { useSelector } from "react-redux";
import { useLocation, useNavigate } from "react-router-dom";
import { useGetProcurementTrackersByAgreementIdQuery, useGetUsersQuery } from "../../../api/opsAPI";
import ProcurementTrackerStepOne from "../../../components/Agreements/ProcurementTracker/ProcurementTrackerStepOne";
@@ -12,6 +13,7 @@ import SimpleAlert from "../../../components/UI/Alert/SimpleAlert";
import StepIndicator from "../../../components/UI/StepIndicator";
import { IS_PROCUREMENT_TRACKER_READY_MAP } from "../../../constants";
import { useIsUserSuperUser, useIsUserOnlyProcurementTeam } from "../../../hooks/user.hooks";
+import { ProcurementTrackerPreAwardApprovalStatus } from "../../../components/Agreements/ProcurementTracker/ProcurementTracker.constants";
/**
* @typedef {Object} AgreementProcurementTrackerProps
@@ -29,6 +31,8 @@ const AgreementProcurementTracker = ({ agreement }) => {
const navigate = useNavigate();
const [showSuccessAlert, setShowSuccessAlert] = React.useState(false);
const [showInReviewAlert, setShowInReviewAlert] = React.useState(false);
+ const [showApprovedAlert, setShowApprovedAlert] = React.useState(false);
+ const [showDeclinedAlert, setShowDeclinedAlert] = React.useState(false);
const isJustSubmittedRef = React.useRef(false);
const WIZARD_STEPS = [
@@ -44,6 +48,7 @@ const AgreementProcurementTracker = ({ agreement }) => {
setCompletedStepNumber(stepNumber);
};
const agreementId = agreement?.id;
+ const currentUserId = useSelector((state) => state.auth?.activeUser?.id);
// Check for success message from location state (runs once on mount)
React.useEffect(() => {
@@ -97,13 +102,33 @@ const AgreementProcurementTracker = ({ agreement }) => {
const stepFiveData = activeTracker?.steps.find((step) => step.step_number === 5);
// Show "In Review" alert when approval is requested and not in the "just submitted" state
+ // Hide alert when pre-award has been approved
React.useEffect(() => {
- if (stepFiveData?.approval_requested && !isJustSubmittedRef.current) {
+ const isApproved = stepFiveData?.approval_status === ProcurementTrackerPreAwardApprovalStatus.APPROVED;
+
+ if (stepFiveData?.approval_requested && !isJustSubmittedRef.current && !isApproved) {
setShowInReviewAlert(true);
- } else if (!stepFiveData?.approval_requested) {
+ } else if (!stepFiveData?.approval_requested || isApproved) {
setShowInReviewAlert(false);
}
- }, [stepFiveData?.approval_requested]);
+ }, [stepFiveData?.approval_requested, stepFiveData?.approval_status]);
+
+ // Show approved/declined alerts only to the user who requested approval
+ React.useEffect(() => {
+ const isRequester = currentUserId && stepFiveData?.approval_requested_by === currentUserId;
+ const approvalStatus = stepFiveData?.approval_status;
+
+ if (isRequester && approvalStatus === ProcurementTrackerPreAwardApprovalStatus.APPROVED) {
+ setShowApprovedAlert(true);
+ setShowDeclinedAlert(false);
+ } else if (isRequester && approvalStatus === ProcurementTrackerPreAwardApprovalStatus.DECLINED) {
+ setShowApprovedAlert(false);
+ setShowDeclinedAlert(true);
+ } else {
+ setShowApprovedAlert(false);
+ setShowDeclinedAlert(false);
+ }
+ }, [currentUserId, stepFiveData?.approval_requested_by, stepFiveData?.approval_status]);
// Handle loading state
if (isLoading) {
@@ -137,6 +162,26 @@ const AgreementProcurementTracker = ({ agreement }) => {
return (
<>
+ {showApprovedAlert && (
+
+ )}
+ {showDeclinedAlert && (
+
+ )}
{showInReviewAlert && (
tracker.status === PROCUREMENT_TRACKER_STATUS.ACTIVE);
+ const activeTracker = trackers.find((tracker) => tracker.status === ProcurementTrackerStatus.ACTIVE);
const step4 = activeTracker?.steps?.find((step) => step.step_number === 4);
const step5 = activeTracker?.steps?.find((step) => step.step_number === 5);
@@ -73,8 +70,7 @@ export default function useRequestPreAwardApproval(agreementId) {
// Check if approval is pending (requested but not yet approved or declined)
// This is used for both the banner and disabling buttons
const isApprovalPending =
- step5?.approval_requested === true &&
- (!step5?.approval_status || step5?.approval_status === "PENDING");
+ step5?.approval_requested === true && (!step5?.approval_status || step5?.approval_status === "PENDING");
// Check if approval has been approved (prevents re-requesting)
const isApprovalApproved = step5?.approval_status === "APPROVED";
@@ -95,7 +91,7 @@ export default function useRequestPreAwardApproval(agreementId) {
const hasBLIInReview = agreement?.budget_line_items?.some((bli) => bli.in_review) ?? false;
// Check if Step 4 (Evaluation) is completed
- const isStep4Completed = step4?.status === PROCUREMENT_STEP_STATUS.COMPLETED;
+ const isStep4Completed = step4?.status === ProcurementTrackerStepStatus.COMPLETED;
const handleFileChange = (e) => {
const file = e.target.files[0];
diff --git a/frontend/src/types/ProcurementTrackerTypes.d.ts b/frontend/src/types/ProcurementTrackerTypes.d.ts
index 9f405c91a0..e26cfef257 100644
--- a/frontend/src/types/ProcurementTrackerTypes.d.ts
+++ b/frontend/src/types/ProcurementTrackerTypes.d.ts
@@ -1,3 +1,5 @@
+export type ProcurementTrackerStepStatus = "PENDING" | "ACTIVE" | "COMPLETED" | "SKIPPED";
+
export type ProcurementTrackerStatus = "ACTIVE" | "INACTIVE" | "COMPLETED";
export type ProcurementTrackerType = "DEFAULT";
@@ -10,8 +12,6 @@ export type ProcurementTrackerStepType =
| "PRE_AWARD"
| "AWARD";
-export type ProcurementTrackerStepStatus = "PENDING" | "ACTIVE" | "COMPLETED" | "SKIPPED";
-
export type ProcurementTrackerStep = {
id: number;
procurement_tracker_id: number;
@@ -51,21 +51,19 @@ export type ProcurementTrackerEvaluationStep = ProcurementTrackerStep & {
notes?: string | null;
};
+export type ProcurementTrackerPreAwardApprovalStatus = "PENDING" | "REQUESTED" | "APPROVED" | "DECLINED" | "CANCELLED";
+
export type ProcurementTrackerPreAwardStep = ProcurementTrackerStep & {
target_completion_date?: string | null;
task_completed_by?: number | null;
date_completed?: string | null;
notes?: string | null;
- // Pre-Award approval request fields (generic names from API)
+ // Pre-Award approval request fields
approval_requested?: boolean | null;
approval_requested_date?: string | null;
approval_requested_by?: number | null;
+ approval_status?: ProcurementTrackerPreAwardApprovalStatus | null;
requestor_notes?: string | null;
- // Pre-Award approval request fields (model-specific names)
- pre_award_approval_requested?: boolean | null;
- pre_award_approval_requested_date?: string | null;
- pre_award_approval_requested_by?: number | null;
- pre_award_requestor_notes?: string | null;
};
export type ProcurementTrackerResponseStep =
From f6c4d06cc27653acc1088d383669837458bd52db Mon Sep 17 00:00:00 2001
From: josbell
Date: Tue, 31 Mar 2026 20:11:46 -0700
Subject: [PATCH 18/81] fix: hide in-review alert for requester when pre-award
declined
When a director declines a pre-award approval request, the requester
now sees only the declined alert (not both in-review and declined).
Non-requesters continue to see the in-review alert as expected.
Co-Authored-By: Claude Sonnet 4.5
---
.../details/AgreementProcurementTracker.jsx | 23 +++++++++++++------
1 file changed, 16 insertions(+), 7 deletions(-)
diff --git a/frontend/src/pages/agreements/details/AgreementProcurementTracker.jsx b/frontend/src/pages/agreements/details/AgreementProcurementTracker.jsx
index 657f581601..e564fd85a0 100644
--- a/frontend/src/pages/agreements/details/AgreementProcurementTracker.jsx
+++ b/frontend/src/pages/agreements/details/AgreementProcurementTracker.jsx
@@ -102,16 +102,25 @@ const AgreementProcurementTracker = ({ agreement }) => {
const stepFiveData = activeTracker?.steps.find((step) => step.step_number === 5);
// Show "In Review" alert when approval is requested and not in the "just submitted" state
- // Hide alert when pre-award has been approved
+ // Hide alert when pre-award has been approved or when declined for the requester
React.useEffect(() => {
const isApproved = stepFiveData?.approval_status === ProcurementTrackerPreAwardApprovalStatus.APPROVED;
+ const isDeclined = stepFiveData?.approval_status === ProcurementTrackerPreAwardApprovalStatus.DECLINED;
+ const isRequester = currentUserId && stepFiveData?.approval_requested_by === currentUserId;
- if (stepFiveData?.approval_requested && !isJustSubmittedRef.current && !isApproved) {
- setShowInReviewAlert(true);
- } else if (!stepFiveData?.approval_requested || isApproved) {
- setShowInReviewAlert(false);
- }
- }, [stepFiveData?.approval_requested, stepFiveData?.approval_status]);
+ // Show "In Review" alert when:
+ // - Approval is requested
+ // - Not just submitted (5 second grace period)
+ // - NOT approved (approved = done, no alert for anyone)
+ // - If declined, only show for non-requesters (requester sees declined alert instead)
+ const shouldShowInReview =
+ stepFiveData?.approval_requested &&
+ !isJustSubmittedRef.current &&
+ !isApproved &&
+ !(isDeclined && isRequester);
+
+ setShowInReviewAlert(shouldShowInReview);
+ }, [stepFiveData?.approval_requested, stepFiveData?.approval_status, currentUserId, stepFiveData?.approval_requested_by]);
// Show approved/declined alerts only to the user who requested approval
React.useEffect(() => {
From 5e0f2faad9305bd5e9f6c5d9bca29f78374b744b Mon Sep 17 00:00:00 2001
From: josbell
Date: Wed, 1 Apr 2026 08:13:07 -0700
Subject: [PATCH 19/81] fix: resolve infinite render loop in pre-award approval
hooks
- Add shallowEqual to userRoles selector to prevent new array refs
- Memoize executingBudgetLines to stabilize dependency array
- Fix approvalAlreadyProcessed to return boolean instead of null
- Add JSDoc type annotations for callback parameters
- Refactor tests to use proper Redux store setup
- Remove debug console.log from RequestPreAwardApproval
Fixes infinite loop causing 6 test failures in ApprovePreAwardApproval.hooks.test.js
Co-Authored-By: Claude Sonnet 4.5
---
.../ApprovePreAwardApproval.hooks.js | 34 ++--
.../ApprovePreAwardApproval.hooks.test.js | 171 ++++++++----------
.../RequestPreAwardApproval.hooks.js | 14 +-
frontend/src/store.js | 9 +-
4 files changed, 108 insertions(+), 120 deletions(-)
diff --git a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.js b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.js
index 81c56a1076..14193732d3 100644
--- a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.js
+++ b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.js
@@ -1,6 +1,6 @@
import { useState, useMemo } from "react";
import { useNavigate } from "react-router-dom";
-import { useSelector } from "react-redux";
+import { useSelector, shallowEqual } from "react-redux";
import {
useGetAgreementByIdQuery,
useGetServicesComponentsListQuery,
@@ -26,8 +26,11 @@ export default function useApprovePreAwardApproval(agreementId) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState("");
+ // Use separate selectors with shallowEqual to prevent infinite loops
+ // @ts-expect-error - Redux state typing in JS files
const userId = useSelector((state) => state.auth?.activeUser?.id) ?? null;
- const userRoles = useSelector((state) => state.auth?.activeUser?.roles) ?? [];
+ // @ts-expect-error - Redux state typing in JS files
+ const userRoles = useSelector((state) => state.auth?.activeUser?.roles ?? [], shallowEqual);
const { data: agreement, isLoading } = useGetAgreementByIdQuery(agreementId);
const [updateProcurementTrackerStep] = useUpdateProcurementTrackerStepMutation();
@@ -40,8 +43,11 @@ export default function useApprovePreAwardApproval(agreementId) {
const projectOfficerName = useGetUserFullNameFromId(agreement?.project_officer_id);
const alternateProjectOfficerName = useGetUserFullNameFromId(agreement?.alternate_project_officer_id);
- // Get executing budget lines
- const executingBudgetLines = agreement?.budget_line_items?.filter((bli) => bli.status === "IN_EXECUTION") || [];
+ // Get executing budget lines (memoized to prevent infinite loops in hasPermission)
+ const executingBudgetLines = useMemo(
+ () => agreement?.budget_line_items?.filter(/** @param {any} bli */ (bli) => bli.status === "IN_EXECUTION") ?? [],
+ [agreement?.budget_line_items]
+ );
// Group budget lines by services component
const groupedBudgetLinesByServicesComponent = groupByServicesComponent(
@@ -51,23 +57,23 @@ export default function useApprovePreAwardApproval(agreementId) {
// Get Step 5 (Pre-Award) from procurement tracker
const trackers = procurementTrackersData?.data || [];
- const activeTracker = trackers.find((tracker) => tracker.status === "ACTIVE");
- const step5 = activeTracker?.steps?.find((step) => step.step_number === 5);
+ const activeTracker = trackers.find(/** @param {any} tracker */ (tracker) => tracker.status === "ACTIVE");
+ const step5 = activeTracker?.steps?.find(/** @param {any} step */ (step) => step.step_number === 5);
// Get existing Pre-Award Consensus Memo documents
const preAwardMemoDocuments =
- documentsData?.documents?.filter((doc) => doc.document_type === "PRE_AWARD_CONSENSUS_MEMO") || [];
+ documentsData?.documents?.filter(/** @param {any} doc */ (doc) => doc.document_type === "PRE_AWARD_CONSENSUS_MEMO") || [];
// Get submitter's notes
const requestorNotes = step5?.requestor_notes || "";
- // Check if approval already processed
- const approvalAlreadyProcessed = step5?.approval_status && step5.approval_status !== "PENDING";
+ // Check if approval already processed (returns boolean, not null)
+ const approvalAlreadyProcessed = Boolean(step5?.approval_status && step5.approval_status !== "PENDING");
// Permission check: user is Division Director, Deputy Director, Budget Team, or System Owner
const hasPermission = useMemo(() => {
- // Check if user has required roles
- const userRoleNames = userRoles.map((role) => role?.name);
+ const userRoleNames = userRoles.map(/** @param {any} role */ (role) => role?.name);
+
const hasRequiredRole =
userRoleNames.includes("BUDGET_TEAM") ||
userRoleNames.includes("SYSTEM_OWNER") ||
@@ -83,7 +89,7 @@ export default function useApprovePreAwardApproval(agreementId) {
// For REVIEWER_APPROVER, check if user is division director/deputy for any CAN in executing budget lines
if (userRoleNames.includes("REVIEWER_APPROVER")) {
return executingBudgetLines.some(
- (bli) =>
+ /** @param {any} bli */ (bli) =>
bli.can?.portfolio?.division?.division_director_id === userId ||
bli.can?.portfolio?.division?.deputy_division_director_id === userId
);
@@ -92,6 +98,9 @@ export default function useApprovePreAwardApproval(agreementId) {
return false;
}, [userRoles, userId, executingBudgetLines]);
+ /**
+ * @param {"APPROVED" | "DECLINED"} action
+ */
const handleAction = async (action) => {
if (!step5?.id) {
setSubmitError("Step 5 not found. Cannot process approval request.");
@@ -131,6 +140,7 @@ export default function useApprovePreAwardApproval(agreementId) {
} catch (error) {
console.error(`Failed to ${actionText} approval request:`, error);
setSubmitError(
+ // @ts-expect-error - RTK Query error has data property
error?.data?.error || `Failed to ${actionText} approval request. Please try again.`
);
setIsSubmitting(false);
diff --git a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.test.js b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.test.js
index e1b89ae158..8f939579a3 100644
--- a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.test.js
+++ b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.hooks.test.js
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { Provider } from "react-redux";
import { MemoryRouter } from "react-router-dom";
-import store from "../../../store";
+import { setupStore } from "../../../store";
import useApprovePreAwardApproval from "./ApprovePreAwardApproval.hooks";
// Mock the API hooks
@@ -46,11 +46,25 @@ import useGetUserFullNameFromId from "../../../hooks/user.hooks";
import useAlert from "../../../hooks/use-alert.hooks";
import { groupByServicesComponent } from "../../../helpers/budgetLines.helpers";
-const wrapper = ({ children }) => (
-
- {children}
-
-);
+// Helper to create test store with auth state
+const createTestStore = (authState = {}) => {
+ return setupStore({
+ auth: {
+ activeUser: authState.activeUser || null,
+ ...authState
+ }
+ });
+};
+
+const createWrapper = (store) => {
+ const Wrapper = ({ children }) => (
+
+ {children}
+
+ );
+ Wrapper.displayName = "TestWrapper";
+ return Wrapper;
+};
describe("useApprovePreAwardApproval", () => {
const mockAgreement = {
@@ -123,6 +137,8 @@ describe("useApprovePreAwardApproval", () => {
});
it("should return initial state correctly", () => {
+ const store = createTestStore();
+ const wrapper = createWrapper(store);
const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.agreement).toEqual(mockAgreement);
@@ -134,6 +150,8 @@ describe("useApprovePreAwardApproval", () => {
});
it("should filter executing budget lines correctly", () => {
+ const store = createTestStore();
+ const wrapper = createWrapper(store);
const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.executingBudgetLines).toHaveLength(1);
@@ -141,12 +159,16 @@ describe("useApprovePreAwardApproval", () => {
});
it("should extract step 5 data correctly", () => {
+ const store = createTestStore();
+ const wrapper = createWrapper(store);
const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.requestorNotes).toBe("Please review");
});
it("should identify approval as not processed when status is null", () => {
+ const store = createTestStore();
+ const wrapper = createWrapper(store);
const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.approvalAlreadyProcessed).toBe(false);
@@ -165,128 +187,79 @@ describe("useApprovePreAwardApproval", () => {
}
});
+ const store = createTestStore();
+ const wrapper = createWrapper(store);
const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.approvalAlreadyProcessed).toBe(true);
});
it("should grant permission to BUDGET_TEAM users", () => {
- // Mock Redux store state for BUDGET_TEAM user
- const mockStore = {
- ...store,
- getState: () => ({
- auth: {
- activeUser: {
- id: 200,
- roles: [{ name: "BUDGET_TEAM" }]
- }
- }
- })
- };
-
- const customWrapper = ({ children }) => (
-
- {children}
-
- );
+ const store = createTestStore({
+ activeUser: {
+ id: 200,
+ roles: [{ name: "BUDGET_TEAM" }]
+ }
+ });
+ const wrapper = createWrapper(store);
- const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.hasPermission).toBe(true);
});
it("should grant permission to SYSTEM_OWNER users", () => {
- const mockStore = {
- ...store,
- getState: () => ({
- auth: {
- activeUser: {
- id: 200,
- roles: [{ name: "SYSTEM_OWNER" }]
- }
- }
- })
- };
-
- const customWrapper = ({ children }) => (
-
- {children}
-
- );
+ const store = createTestStore({
+ activeUser: {
+ id: 200,
+ roles: [{ name: "SYSTEM_OWNER" }]
+ }
+ });
+ const wrapper = createWrapper(store);
- const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.hasPermission).toBe(true);
});
it("should grant permission to REVIEWER_APPROVER who is division director", () => {
- const mockStore = {
- ...store,
- getState: () => ({
- auth: {
- activeUser: {
- id: 100, // matches division_director_id
- roles: [{ name: "REVIEWER_APPROVER" }]
- }
- }
- })
- };
-
- const customWrapper = ({ children }) => (
-
- {children}
-
- );
+ const store = createTestStore({
+ activeUser: {
+ id: 100, // matches division_director_id
+ roles: [{ name: "REVIEWER_APPROVER" }]
+ }
+ });
+ const wrapper = createWrapper(store);
- const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.hasPermission).toBe(true);
});
it("should grant permission to REVIEWER_APPROVER who is deputy division director", () => {
- const mockStore = {
- ...store,
- getState: () => ({
- auth: {
- activeUser: {
- id: 101, // matches deputy_division_director_id
- roles: [{ name: "REVIEWER_APPROVER" }]
- }
- }
- })
- };
-
- const customWrapper = ({ children }) => (
-
- {children}
-
- );
+ const store = createTestStore({
+ activeUser: {
+ id: 101, // matches deputy_division_director_id
+ roles: [{ name: "REVIEWER_APPROVER" }]
+ }
+ });
+ const wrapper = createWrapper(store);
- const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.hasPermission).toBe(true);
});
it("should deny permission to users without required roles", () => {
- const mockStore = {
- ...store,
- getState: () => ({
- auth: {
- activeUser: {
- id: 999,
- roles: [{ name: "VIEWER" }]
- }
- }
- })
- };
-
- const customWrapper = ({ children }) => (
-
- {children}
-
- );
+ const store = createTestStore({
+ activeUser: {
+ id: 999,
+ roles: [{ name: "VIEWER" }]
+ }
+ });
+ const wrapper = createWrapper(store);
- const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper: customWrapper });
+ const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.hasPermission).toBe(false);
});
@@ -301,6 +274,8 @@ describe("useApprovePreAwardApproval", () => {
}
});
+ const store = createTestStore();
+ const wrapper = createWrapper(store);
const { result } = renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(result.current.preAwardMemoDocuments).toHaveLength(1);
@@ -308,6 +283,8 @@ describe("useApprovePreAwardApproval", () => {
});
it("should call groupByServicesComponent with executing budget lines", () => {
+ const store = createTestStore();
+ const wrapper = createWrapper(store);
renderHook(() => useApprovePreAwardApproval(1), { wrapper });
expect(groupByServicesComponent).toHaveBeenCalledWith(
diff --git a/frontend/src/pages/agreements/pre-award-approval/RequestPreAwardApproval.hooks.js b/frontend/src/pages/agreements/pre-award-approval/RequestPreAwardApproval.hooks.js
index fc7153d355..e429917c1d 100644
--- a/frontend/src/pages/agreements/pre-award-approval/RequestPreAwardApproval.hooks.js
+++ b/frontend/src/pages/agreements/pre-award-approval/RequestPreAwardApproval.hooks.js
@@ -19,7 +19,10 @@ import {
uploadDocumentToBlob,
uploadDocumentToInMemory
} from "../../../components/Agreements/Documents/Document";
-import { ProcurementTrackerStepStatus, ProcurementTrackerStatus } from "../../../components/Agreements/ProcurementTracker/ProcurementTracker.constants";
+import {
+ ProcurementTrackerStepStatus,
+ ProcurementTrackerStatus
+} from "../../../components/Agreements/ProcurementTracker/ProcurementTracker.constants";
/**
* Custom hook for the Request Pre-Award Approval page
@@ -78,15 +81,6 @@ export default function useRequestPreAwardApproval(agreementId) {
// Disable editing when pending OR approved (but allow re-request when declined)
const hasApprovalBeenRequested = isApprovalPending || isApprovalApproved;
- console.log("🔍 Pre-Award Approval State:", {
- step5_approval_requested: step5?.approval_requested,
- step5_approval_status: step5?.approval_status,
- step5_approval_responded_by: step5?.approval_responded_by,
- isApprovalPending,
- isApprovalApproved,
- hasApprovalBeenRequested
- });
-
// Check if any BLI is in review status
const hasBLIInReview = agreement?.budget_line_items?.some((bli) => bli.in_review) ?? false;
diff --git a/frontend/src/store.js b/frontend/src/store.js
index 99805467b0..bc76f47801 100644
--- a/frontend/src/store.js
+++ b/frontend/src/store.js
@@ -35,7 +35,7 @@ export const setupStore = (preloadedState = {}) => {
});
};
-export default configureStore({
+const store = configureStore({
reducer: rootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(
@@ -45,3 +45,10 @@ export default configureStore({
resetApiOnLogoutMiddleware
)
});
+
+// Export RootState type for use with useSelector
+/**
+ * @typedef {ReturnType} RootState
+ */
+
+export default store;
From b17fee7950d8441dd623263b5c02dfa004604339 Mon Sep 17 00:00:00 2001
From: josbell
Date: Wed, 1 Apr 2026 08:28:21 -0700
Subject: [PATCH 20/81] fix: correct test mocks for pre-award approval
components
- Updated TextArea mock in ApprovePreAwardApproval.test.jsx to be uncontrolled
- Fixed SimpleAlert mock to support both message prop and children
- Changed assertion to toHaveBeenLastCalledWith for user.type() test
- Updated button query to use getAllByRole for multiple "Processing..." buttons
- Added missing isApprovalPending property to RequestPreAwardApproval test mock
Co-Authored-By: Claude Sonnet 4.5
---
.../ApprovePreAwardApproval.test.jsx | 18 ++++++++++++------
.../RequestPreAwardApproval.test.jsx | 3 +++
2 files changed, 15 insertions(+), 6 deletions(-)
diff --git a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.test.jsx b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.test.jsx
index 67fb9dd2e3..d6fa2aa201 100644
--- a/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.test.jsx
+++ b/frontend/src/pages/agreements/pre-award-approval/ApprovePreAwardApproval.test.jsx
@@ -47,10 +47,9 @@ vi.mock("../../../components/UI/Accordion", () => ({
}));
vi.mock("../../../components/UI/Form/TextArea", () => ({
- default: ({ value, onChange, disabled, maxLength }) => (
+ default: ({ onChange, disabled, maxLength }) => (