diff --git a/backend/app/routes/match.py b/backend/app/routes/match.py index 2ceeff7b..6c3cb54c 100644 --- a/backend/app/routes/match.py +++ b/backend/app/routes/match.py @@ -89,8 +89,24 @@ async def get_matches_for_participant( match_service: MatchService = Depends(get_match_service), _authorized: bool = has_roles([UserRole.ADMIN]), ): + """Get matches for a participant (admin only). Includes all matches including awaiting_volunteer_acceptance.""" try: - return await match_service.get_matches_for_participant(participant_id) + return await match_service.get_all_matches_for_participant_admin(participant_id) + except HTTPException as http_ex: + raise http_ex + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/volunteer/{volunteer_id}", response_model=MatchListForVolunteerResponse) +async def get_matches_for_volunteer_admin( + volunteer_id: UUID, + match_service: MatchService = Depends(get_match_service), + _authorized: bool = has_roles([UserRole.ADMIN]), +): + """Get all matches for a volunteer (admin only).""" + try: + return await match_service.get_matches_for_volunteer(volunteer_id) except HTTPException as http_ex: raise http_ex except Exception as e: diff --git a/backend/app/schemas/match.py b/backend/app/schemas/match.py index e6575eaa..23cfc6ea 100644 --- a/backend/app/schemas/match.py +++ b/backend/app/schemas/match.py @@ -55,6 +55,9 @@ class MatchVolunteerSummary(BaseModel): treatments: List[str] = Field(default_factory=list) experiences: List[str] = Field(default_factory=list) overview: Optional[str] = None # Volunteer experience/overview from volunteer_data + loved_one_diagnosis: Optional[str] = None + loved_one_treatments: List[str] = Field(default_factory=list) + loved_one_experiences: List[str] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) @@ -70,6 +73,9 @@ class MatchParticipantSummary(BaseModel): treatments: List[str] = Field(default_factory=list) experiences: List[str] = Field(default_factory=list) timezone: Optional[str] = None + loved_one_diagnosis: Optional[str] = None + loved_one_treatments: List[str] = Field(default_factory=list) + loved_one_experiences: List[str] = Field(default_factory=list) model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/matching.py b/backend/app/schemas/matching.py index 83509b9c..277722c7 100644 --- a/backend/app/schemas/matching.py +++ b/backend/app/schemas/matching.py @@ -40,9 +40,21 @@ class AdminMatchCandidate(BaseModel): timezone: Optional[str] age: Optional[int] diagnosis: Optional[str] + date_of_diagnosis: Optional[str] = None # ISO format date string treatments: List[str] = [] experiences: List[str] = [] match_score: float # 0-100 scale + match_count: int = 0 # Number of active matches for this volunteer + # Additional fields for dynamic columns based on preferences + marital_status: Optional[str] = None + gender_identity: Optional[str] = None + ethnic_group: Optional[List[str]] = None + has_kids: Optional[str] = None + loved_one_age: Optional[str] = None + loved_one_diagnosis: Optional[str] = None + loved_one_date_of_diagnosis: Optional[str] = None # ISO format date string + loved_one_treatments: List[str] = [] + loved_one_experiences: List[str] = [] class AdminMatchesResponse(BaseModel): diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index 577757b8..5057a008 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -128,6 +128,7 @@ class UserCreateResponse(BaseModel): auth_id: str approved: bool active: bool + pending_volunteer_request: bool form_status: FormStatus language: Language @@ -148,12 +149,14 @@ class UserResponse(BaseModel): auth_id: str approved: bool active: bool + pending_volunteer_request: bool role: "RoleResponse" form_status: FormStatus user_data: Optional[UserDataResponse] = None volunteer_data: Optional[VolunteerDataResponse] = None availability: List[AvailabilityTemplateSlot] = [] language: Language + match_count: int = Field(default=0, description="Number of active matches for this user") model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/user_data.py b/backend/app/schemas/user_data.py index da961e3e..119b66c2 100644 --- a/backend/app/schemas/user_data.py +++ b/backend/app/schemas/user_data.py @@ -91,6 +91,7 @@ class UserDataUpdateRequest(BaseModel): marital_status: Optional[str] = None has_kids: Optional[str] = None timezone: Optional[str] = None + language: Optional[str] = Field(None, description="Preferred language (en or fr)") # User's Cancer Experience diagnosis: Optional[str] = None diff --git a/backend/app/seeds/users.py b/backend/app/seeds/users.py index 90903ada..3eec10b3 100644 --- a/backend/app/seeds/users.py +++ b/backend/app/seeds/users.py @@ -357,7 +357,7 @@ def seed_users(session: Session) -> None: "city": "Calgary", "province": "Alberta", "postal_code": "T2P 1J4", - "gender_identity": "Man", + "gender_identity": "Male", "pronouns": ["he", "him"], "ethnic_group": ["White/Caucasian"], "marital_status": "Single", @@ -366,10 +366,15 @@ def seed_users(session: Session) -> None: "diagnosis": "Non-Hodgkin Lymphoma", "date_of_diagnosis": date(2019, 6, 10), "has_blood_cancer": "yes", - "caring_for_someone": "no", + "caring_for_someone": "yes", + "loved_one_age": "26", + "loved_one_diagnosis": "Blood Cancer", + "loved_one_date_of_diagnosis": date(2023, 2, 20), }, "treatments": [3, 6, 14], # Chemotherapy, Radiation, BTK Inhibitors "experiences": [1, 3, 4, 7], # Brain Fog, Feeling Overwhelmed, Fatigue, Returning to work + "loved_one_treatments": [3, 6], # Chemotherapy, Radiation + "loved_one_experiences": [1, 3, 4, 7], # Brain Fog, Feeling Overwhelmed, Fatigue, Returning to work }, { "role": "volunteer", diff --git a/backend/app/server.py b/backend/app/server.py index 0beb6110..e38e4f99 100644 --- a/backend/app/server.py +++ b/backend/app/server.py @@ -27,6 +27,7 @@ ) from .services.implementations.match_completion_service import MatchCompletionService from .utilities.constants import LOGGER_NAME +from .utilities.db_utils import engine from .utilities.firebase_init import initialize_firebase from .utilities.ses.ses_init import ensure_ses_templates @@ -87,6 +88,11 @@ async def lifespan(_: FastAPI): # Shutdown scheduler gracefully log.info("Shutting down scheduler...") scheduler.shutdown() + + # Dispose database engine to close all connection pools + # This prevents async generator cleanup errors during shutdown + log.info("Disposing database engine...") + engine.dispose() log.info("Shutting down...") diff --git a/backend/app/services/implementations/form_processor.py b/backend/app/services/implementations/form_processor.py index beffa1e5..b16753e2 100644 --- a/backend/app/services/implementations/form_processor.py +++ b/backend/app/services/implementations/form_processor.py @@ -106,10 +106,10 @@ def _process_ranking_form(self, submission: FormSubmission, user: User) -> None: if not target: raise ValueError("Ranking form missing 'target' field") - # Delete existing preferences for this user/target + # Delete ALL existing preferences for this user (not just for this target_role) + # This ensures a user can only have preferences for one target_role at a time self.db.query(RankingPreference).filter( RankingPreference.user_id == user.id, - RankingPreference.target_role == target, ).delete(synchronize_session=False) # Create new preference records @@ -135,6 +135,9 @@ def _process_ranking_form(self, submission: FormSubmission, user: User) -> None: if user.form_status in (FormStatus.RANKING_TODO, FormStatus.RANKING_SUBMITTED): user.form_status = FormStatus.COMPLETED + # Set pending_volunteer_request flag so admin knows to create matches + user.pending_volunteer_request = True + # Create a MATCHING task so admins know this participant is ready to be matched try: matching_task = Task( diff --git a/backend/app/services/implementations/match_service.py b/backend/app/services/implementations/match_service.py index f42c4a16..678abb84 100644 --- a/backend/app/services/implementations/match_service.py +++ b/backend/app/services/implementations/match_service.py @@ -660,6 +660,8 @@ async def get_matches_for_participant(self, participant_id: UUID) -> MatchListRe .options( joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.treatments), joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.experiences), + joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.loved_one_treatments), + joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.loved_one_experiences), joinedload(Match.volunteer).joinedload(User.volunteer_data), joinedload(Match.match_status), joinedload(Match.suggested_time_blocks), @@ -685,6 +687,46 @@ async def get_matches_for_participant(self, participant_id: UUID) -> MatchListRe self.logger.error(f"Error fetching matches for participant {participant_id}: {exc}") raise HTTPException(status_code=500, detail="Failed to fetch matches") + async def get_all_matches_for_participant_admin(self, participant_id: UUID) -> MatchListResponse: + """Get all matches for a participant including those awaiting volunteer acceptance (admin only).""" + try: + # Get participant to check pending request flag + participant: User | None = self.db.get(User, participant_id) + if not participant: + raise HTTPException(404, f"Participant {participant_id} not found") + + # Get ALL matches including those awaiting volunteer acceptance + matches: List[Match] = ( + self.db.query(Match) + .options( + joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.treatments), + joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.experiences), + joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.loved_one_treatments), + joinedload(Match.volunteer).joinedload(User.user_data).joinedload(UserData.loved_one_experiences), + joinedload(Match.volunteer).joinedload(User.volunteer_data), + joinedload(Match.match_status), + joinedload(Match.suggested_time_blocks), + joinedload(Match.confirmed_time), + ) + .filter( + Match.participant_id == participant_id, + Match.deleted_at.is_(None), + ) + .order_by(Match.created_at.desc()) + .all() + ) + + responses = [self._build_match_detail(match) for match in matches] + return MatchListResponse( + matches=responses, + has_pending_request=participant.pending_volunteer_request or False, + ) + except HTTPException: + raise + except Exception as exc: + self.logger.error(f"Error fetching all matches for participant {participant_id}: {exc}") + raise HTTPException(status_code=500, detail="Failed to fetch matches") + async def get_matches_for_volunteer(self, volunteer_id: UUID) -> MatchListForVolunteerResponse: """Get all matches for a volunteer, including those awaiting acceptance.""" try: @@ -699,6 +741,8 @@ async def get_matches_for_volunteer(self, volunteer_id: UUID) -> MatchListForVol .options( joinedload(Match.participant).joinedload(User.user_data).joinedload(UserData.treatments), joinedload(Match.participant).joinedload(User.user_data).joinedload(UserData.experiences), + joinedload(Match.participant).joinedload(User.user_data).joinedload(UserData.loved_one_treatments), + joinedload(Match.participant).joinedload(User.user_data).joinedload(UserData.loved_one_experiences), joinedload(Match.match_status), ) .filter(Match.volunteer_id == volunteer_id, Match.deleted_at.is_(None)) @@ -822,6 +866,9 @@ def _build_match_detail_for_volunteer(self, match: Match) -> MatchDetailForVolun treatments: List[str] = [] experiences: List[str] = [] timezone: Optional[str] = None + loved_one_diagnosis: Optional[str] = None + loved_one_treatments: List[str] = [] + loved_one_experiences: List[str] = [] if participant_data: if participant_data.pronouns: @@ -839,6 +886,13 @@ def _build_match_detail_for_volunteer(self, match: Match) -> MatchDetailForVolun timezone = participant_data.timezone + # Add loved one data + loved_one_diagnosis = participant_data.loved_one_diagnosis + if participant_data.loved_one_treatments: + loved_one_treatments = [t.name for t in participant_data.loved_one_treatments if t and t.name] + if participant_data.loved_one_experiences: + loved_one_experiences = [e.name for e in participant_data.loved_one_experiences if e and e.name] + participant_summary = MatchParticipantSummary( id=participant.id, first_name=participant.first_name, @@ -850,6 +904,9 @@ def _build_match_detail_for_volunteer(self, match: Match) -> MatchDetailForVolun treatments=treatments, experiences=experiences, timezone=timezone, + loved_one_diagnosis=loved_one_diagnosis, + loved_one_treatments=loved_one_treatments, + loved_one_experiences=loved_one_experiences, ) match_status_name = match.match_status.name if match.match_status else "" @@ -940,6 +997,9 @@ def _build_match_detail(self, match: Match) -> MatchDetailResponse: treatments: List[str] = [] experiences: List[str] = [] overview: Optional[str] = None + loved_one_diagnosis: Optional[str] = None + loved_one_treatments: List[str] = [] + loved_one_experiences: List[str] = [] if volunteer_data: if volunteer_data.pronouns: @@ -957,6 +1017,13 @@ def _build_match_detail(self, match: Match) -> MatchDetailResponse: if volunteer_data.experiences: experiences = [e.name for e in volunteer_data.experiences if e and e.name] + # Add loved one data + loved_one_diagnosis = volunteer_data.loved_one_diagnosis + if volunteer_data.loved_one_treatments: + loved_one_treatments = [t.name for t in volunteer_data.loved_one_treatments if t and t.name] + if volunteer_data.loved_one_experiences: + loved_one_experiences = [e.name for e in volunteer_data.loved_one_experiences if e and e.name] + # Get overview/experience from volunteer_data table if volunteer_data_record and volunteer_data_record.experience: overview = volunteer_data_record.experience @@ -974,6 +1041,9 @@ def _build_match_detail(self, match: Match) -> MatchDetailResponse: treatments=treatments, experiences=experiences, overview=overview, + loved_one_diagnosis=loved_one_diagnosis, + loved_one_treatments=loved_one_treatments, + loved_one_experiences=loved_one_experiences, ) suggested_blocks = [ diff --git a/backend/app/services/implementations/matching_service.py b/backend/app/services/implementations/matching_service.py index f7fd3175..65b58a17 100644 --- a/backend/app/services/implementations/matching_service.py +++ b/backend/app/services/implementations/matching_service.py @@ -9,6 +9,8 @@ from app.interfaces.matching_service import IMatchingService from app.models.Experience import Experience +from app.models.Match import Match +from app.models.MatchStatus import MatchStatus from app.models.Quality import Quality from app.models.RankingPreference import RankingPreference from app.models.Role import Role @@ -51,7 +53,10 @@ async def get_matches(self, participant_id: UUID, limit: Optional[int] = 5) -> L if not participant_preferences: raise ValueError(f"User with ID {participant_id} has no ranking form data") - # Get all active, approved volunteers with their data + # Get participant's language preference + participant_language = user.language + + # Get all active, approved volunteers with their data and matching language volunteers_with_data = ( self.db.query(User, UserData) .join(User.role) @@ -59,6 +64,7 @@ async def get_matches(self, participant_id: UUID, limit: Optional[int] = 5) -> L .filter(Role.name == UserRole.VOLUNTEER) .filter(User.active) .filter(User.approved) + .filter(User.language == participant_language) .all() ) @@ -124,8 +130,12 @@ async def get_admin_matches(self, participant_id: UUID) -> List[Dict[str, Any]]: if not participant_preferences: raise ValueError(f"User with ID {participant_id} has no ranking form data") + # Get participant's language preference + participant_language = user.language + # Get all active, approved volunteers with their data and relationships # Eagerly load user_data, treatments, and experiences to avoid N+1 queries + # Filter by matching language volunteers = ( self.db.query(User) .join(User.role) @@ -136,6 +146,7 @@ async def get_admin_matches(self, participant_id: UUID) -> List[Dict[str, Any]]: .filter(Role.name == UserRole.VOLUNTEER) .filter(User.active) .filter(User.approved) + .filter(User.language == participant_language) .all() ) @@ -166,6 +177,56 @@ async def get_admin_matches(self, participant_id: UUID) -> List[Dict[str, Any]]: experience_names = [experience.name for experience in volunteer_data.experiences] + # Loved one data + loved_one_treatment_names = [treatment.name for treatment in volunteer_data.loved_one_treatments] + loved_one_experience_names = [experience.name for experience in volunteer_data.loved_one_experiences] + + # Format ethnic_group as list if it's a list, otherwise None + ethnic_group_list = None + if volunteer_data.ethnic_group: + if isinstance(volunteer_data.ethnic_group, list): + ethnic_group_list = volunteer_data.ethnic_group + else: + ethnic_group_list = [volunteer_data.ethnic_group] + + # Count active matches for this volunteer + # Active statuses: pending, requesting_new_times, requesting_new_volunteers, confirmed, awaiting_volunteer_acceptance + active_statuses = ( + self.db.query(MatchStatus) + .filter( + MatchStatus.name.in_( + [ + "pending", + "requesting_new_times", + "requesting_new_volunteers", + "confirmed", + "awaiting_volunteer_acceptance", + ] + ) + ) + .all() + ) + active_status_ids = [status.id for status in active_statuses] + + match_count = ( + self.db.query(Match) + .filter( + Match.volunteer_id == volunteer_user.id, + Match.deleted_at.is_(None), + Match.match_status_id.in_(active_status_ids), + ) + .count() + ) + + # Format dates as ISO strings if they exist + date_of_diagnosis_str = None + if volunteer_data.date_of_diagnosis: + date_of_diagnosis_str = volunteer_data.date_of_diagnosis.isoformat() + + loved_one_date_of_diagnosis_str = None + if volunteer_data.loved_one_date_of_diagnosis: + loved_one_date_of_diagnosis_str = volunteer_data.loved_one_date_of_diagnosis.isoformat() + match_candidate = { "volunteer_id": volunteer_user.id, "first_name": volunteer_user.first_name, @@ -174,9 +235,21 @@ async def get_admin_matches(self, participant_id: UUID) -> List[Dict[str, Any]]: "timezone": volunteer_data.timezone, "age": age, "diagnosis": volunteer_data.diagnosis, + "date_of_diagnosis": date_of_diagnosis_str, "treatments": treatment_names, "experiences": experience_names, "match_score": round(score * 100, 2), + "match_count": match_count, + # Additional fields for dynamic columns + "marital_status": volunteer_data.marital_status, + "gender_identity": volunteer_data.gender_identity, + "ethnic_group": ethnic_group_list, + "has_kids": volunteer_data.has_kids, + "loved_one_age": volunteer_data.loved_one_age, + "loved_one_diagnosis": volunteer_data.loved_one_diagnosis, + "loved_one_date_of_diagnosis": loved_one_date_of_diagnosis_str, + "loved_one_treatments": loved_one_treatment_names, + "loved_one_experiences": loved_one_experience_names, } scored_volunteers.append((match_candidate, score)) @@ -224,11 +297,24 @@ def _get_user_preferences(self, user_id: UUID) -> List[Dict[str, Any]]: return preference_data + def _is_patient_volunteer(self, volunteer_data: UserData) -> bool: + """Check if volunteer is a patient (has cancer and not a caregiver).""" + has_cancer = (volunteer_data.has_blood_cancer or "").lower() == "yes" + is_caregiver = (volunteer_data.caring_for_someone or "").lower() == "yes" + return has_cancer and not is_caregiver + + def _is_caregiver_volunteer(self, volunteer_data: UserData) -> bool: + """Check if volunteer is a caregiver.""" + return (volunteer_data.caring_for_someone or "").lower() == "yes" + def _calculate_match_score( self, participant_data: UserData, volunteer_data: UserData, preferences: List[Dict[str, Any]] ) -> float: """ Calculate match score using complex preference system with target roles, kinds, and scopes. + Each preference's own scope field is used for the participant scope. + Volunteer scope is determined based on the matching case. + Volunteers are filtered by type: patient volunteers for Cases 1 & 2, caregiver volunteers for Case 3. """ # Group preferences by target role @@ -241,20 +327,31 @@ def _calculate_match_score( wants_patient = has_cancer and not is_caregiver # Case 1: Participant (patient) wants cancer patient volunteer + # Only consider patient volunteers (has cancer, not a caregiver) if patient_prefs and wants_patient: - return self._score_preferences(patient_prefs, participant_data, volunteer_data, "self", "self") + if not self._is_patient_volunteer(volunteer_data): + return 0.0 + return self._score_preferences_with_individual_scopes( + patient_prefs, participant_data, volunteer_data, "self" + ) - # Case 2: Participant (caregiver) wants ONLY cancer patient volunteers - # Match caregiver's loved one data with volunteer's patient data - if patient_prefs and is_caregiver and not caregiver_prefs: - return self._score_preferences(patient_prefs, participant_data, volunteer_data, "loved_one", "self") + # Case 2: Participant (caregiver) wants cancer patient volunteers + # Only consider patient volunteers (has cancer, not a caregiver) + if patient_prefs and is_caregiver: + if not self._is_patient_volunteer(volunteer_data): + return 0.0 + return self._score_preferences_with_individual_scopes( + patient_prefs, participant_data, volunteer_data, "self" + ) # Case 3: Participant (caregiver) wants caregiver volunteers - # Match based on both patient and caregiver qualities - if caregiver_prefs and is_caregiver and not patient_prefs: - return self._score_preferences( - caregiver_prefs, participant_data, volunteer_data, "self", "self" - ) + self._score_preferences(patient_prefs, participant_data, volunteer_data, "loved_one", "loved_one") + # Only consider caregiver volunteers + if caregiver_prefs and is_caregiver: + if not self._is_caregiver_volunteer(volunteer_data): + return 0.0 + return self._score_preferences_with_individual_scopes( + caregiver_prefs, participant_data, volunteer_data, None + ) return 0.0 @@ -361,6 +458,53 @@ def serialize_value(value): "loved_one_date_of_diagnosis": serialize_value(user_data.loved_one_date_of_diagnosis), } + def _score_preferences_with_individual_scopes( + self, + preferences: List[Dict[str, Any]], + participant_data: UserData, + volunteer_data: UserData, + volunteer_scope_override: Optional[str], + ) -> float: + """ + Score preferences using each preference's own scope field. + Each preference's scope determines which participant data to use. + Volunteer scope is determined by override (if provided) or matches participant scope. + """ + if not preferences: + return 0.0 + + total_score = 0.0 + max_possible_score = 0.0 + + for pref in preferences: + # Calculate preference weight (higher rank = lower weight) + rank = pref["rank"] + weight = 1.0 / rank # Simple inverse ranking + + # Use the preference's own scope for participant + participant_scope = pref.get("scope", "self") + + # For volunteer scope: use override if provided, otherwise match participant scope + # This handles cases where caregiver wants patient volunteer (volunteer_scope="self") + # vs caregiver wants caregiver volunteer (volunteer_scope matches participant_scope) + volunteer_scope = volunteer_scope_override if volunteer_scope_override is not None else participant_scope + + # Check if volunteer matches this preference using the scopes + match_score = self._check_preference_match( + participant_data, volunteer_data, pref, participant_scope, volunteer_scope + ) + + # Handle both boolean and float returns + if isinstance(match_score, bool): + match_score = 1.0 if match_score else 0.0 + + total_score += weight * match_score + + max_possible_score += weight + + # Return normalized score (0.0 to 1.0) + return total_score / max_possible_score if max_possible_score > 0 else 0.0 + def _score_preferences( self, preferences: List[Dict[str, Any]], @@ -444,6 +588,8 @@ def _check_quality_match( participant_value = participant_data.marital_status elif quality_slug == "same_parental_status": participant_value = participant_data.has_kids + elif quality_slug == "same_ethnic_or_cultural_group": + participant_value = participant_data.ethnic_group else: return False elif participant_scope == "loved_one": @@ -473,6 +619,8 @@ def _check_quality_match( volunteer_value = volunteer_data.marital_status elif quality_slug == "same_parental_status": volunteer_value = volunteer_data.has_kids + elif quality_slug == "same_ethnic_or_cultural_group": + volunteer_value = volunteer_data.ethnic_group else: return False elif volunteer_scope == "loved_one": @@ -494,8 +642,14 @@ def _check_quality_match( # Compare values if quality_slug == "same_age": return self._check_age_similarity(participant_value, volunteer_value) + elif quality_slug == "same_ethnic_or_cultural_group": + return self._check_ethnic_group_overlap(participant_value, volunteer_value) else: - return participant_value == volunteer_value + # For string comparisons, normalize to handle case sensitivity + if isinstance(participant_value, str) and isinstance(volunteer_value, str): + return participant_value.strip().lower() == volunteer_value.strip().lower() + else: + return participant_value == volunteer_value def _check_treatment_match(self, volunteer_data: UserData, treatment: Treatment, volunteer_scope: str) -> bool: """Check if volunteer has experience with the specific treatment.""" @@ -540,6 +694,26 @@ def _softmax(self, values: List[float]) -> List[float]: return [exp_val / sum_exp for exp_val in exp_values] + def _check_ethnic_group_overlap(self, participant_ethnic_groups, volunteer_ethnic_groups) -> bool: + """Check if there's at least one overlapping ethnic group between participant and volunteer.""" + if not participant_ethnic_groups or not volunteer_ethnic_groups: + return False + + # Normalize to lists + participant_list = ( + participant_ethnic_groups if isinstance(participant_ethnic_groups, list) else [participant_ethnic_groups] + ) + volunteer_list = ( + volunteer_ethnic_groups if isinstance(volunteer_ethnic_groups, list) else [volunteer_ethnic_groups] + ) + + # Convert to sets for efficient intersection check + participant_set = {str(g).strip().lower() for g in participant_list if g} + volunteer_set = {str(g).strip().lower() for g in volunteer_list if g} + + # Check if there's at least one overlap + return len(participant_set & volunteer_set) > 0 + def _check_age_similarity(self, participant_age, volunteer_age) -> float: """Calculate age similarity from age values directly.""" if not participant_age or not volunteer_age: diff --git a/backend/app/services/implementations/user_service.py b/backend/app/services/implementations/user_service.py index 4c4d0c57..40ad27c8 100644 --- a/backend/app/services/implementations/user_service.py +++ b/backend/app/services/implementations/user_service.py @@ -23,6 +23,7 @@ UserData, ) from app.models.SuggestedTime import suggested_times +from app.models.User import Language from app.schemas.availability import AvailabilityTemplateSlot from app.schemas.user import ( SignUpMethod, @@ -349,6 +350,34 @@ async def get_users(self) -> List[UserResponse]: .all() ) + # Pre-calculate match counts for all users in one query + # Count all non-deleted matches (regardless of status) + user_ids = [user.id for user in users] + + # Count matches for participants (as participant) + participant_match_counts = ( + self.db.query(Match.participant_id, func.count(Match.id).label("count")) + .filter( + Match.participant_id.in_(user_ids), + Match.deleted_at.is_(None), + ) + .group_by(Match.participant_id) + .all() + ) + participant_counts_dict = {str(pid): count for pid, count in participant_match_counts} + + # Count matches for volunteers (as volunteer) + volunteer_match_counts = ( + self.db.query(Match.volunteer_id, func.count(Match.id).label("count")) + .filter( + Match.volunteer_id.in_(user_ids), + Match.deleted_at.is_(None), + ) + .group_by(Match.volunteer_id) + .all() + ) + volunteer_counts_dict = {str(vid): count for vid, count in volunteer_match_counts} + # Convert templates to AvailabilityTemplateSlot for each user user_responses = [] for user in users: @@ -363,12 +392,21 @@ async def get_users(self) -> List[UserResponse]: ) ) + # Calculate match count based on role + user_id_str = str(user.id) + match_count = 0 + if user.role_id == 1: # Participant + match_count = participant_counts_dict.get(user_id_str, 0) + elif user.role_id == 2: # Volunteer + match_count = volunteer_counts_dict.get(user_id_str, 0) + user_dict = { **{c.name: getattr(user, c.name) for c in user.__table__.columns}, "availability": availability_templates, "role": user.role, "user_data": user.user_data, "volunteer_data": user.volunteer_data, + "match_count": match_count, } user_responses.append(UserResponse.model_validate(user_dict)) @@ -543,6 +581,15 @@ async def update_user_data_by_id(self, user_id: str, user_data_update: UserDataU if "ethnic_group" in update_data: user_data.ethnic_group = update_data["ethnic_group"] + # Handle language (stored on User model, not UserData) + if "language" in update_data: + try: + language_value = update_data["language"] + if language_value in ["en", "fr"]: + db_user.language = Language(language_value) + except (ValueError, AttributeError): + pass # Invalid language value, skip + # Handle treatments (many-to-many) if "treatments" in update_data: user_data.treatments.clear() diff --git a/frontend/src/APIClients/authAPIClient.ts b/frontend/src/APIClients/authAPIClient.ts index 873b5d8f..9e3c560c 100644 --- a/frontend/src/APIClients/authAPIClient.ts +++ b/frontend/src/APIClients/authAPIClient.ts @@ -465,6 +465,7 @@ export const updateUserData = async ( lovedOneDateOfDiagnosis?: string; lovedOneTreatments?: string[]; lovedOneExperiences?: string[]; + language?: string; }, ): Promise => { // Convert camelCase to snake_case for backend @@ -511,6 +512,7 @@ export const updateUserData = async ( backendData.loved_one_treatments = userDataUpdate.lovedOneTreatments; if (userDataUpdate.lovedOneExperiences !== undefined) backendData.loved_one_experiences = userDataUpdate.lovedOneExperiences; + if (userDataUpdate.language !== undefined) backendData.language = userDataUpdate.language; const response = await baseAPIClient.patch( `/users/${userId}/user-data`, diff --git a/frontend/src/APIClients/matchAPIClient.ts b/frontend/src/APIClients/matchAPIClient.ts index 7176ac66..2e4f9ad1 100644 --- a/frontend/src/APIClients/matchAPIClient.ts +++ b/frontend/src/APIClients/matchAPIClient.ts @@ -24,6 +24,77 @@ export interface MatchCreateResponse { matches: MatchResponse[]; } +export interface MatchVolunteerSummary { + id: string; + firstName: string | null; + lastName: string | null; + email: string; + phone: string | null; + pronouns: string[] | null; + diagnosis: string | null; + age: number | null; + timezone: string | null; + treatments: string[]; + experiences: string[]; + overview: string | null; + lovedOneDiagnosis?: string | null; + lovedOneTreatments?: string[]; + lovedOneExperiences?: string[]; +} + +export interface TimeBlockEntity { + id: number; + startTime: string; +} + +export interface MatchDetailResponse { + id: number; + participantId: string; + volunteer: MatchVolunteerSummary; + matchStatus: string; + chosenTimeBlock?: TimeBlockEntity | null; + suggestedTimeBlocks: TimeBlockEntity[]; + createdAt: string; + updatedAt?: string | null; +} + +export interface MatchListResponse { + matches: MatchDetailResponse[]; + hasPendingRequest: boolean; +} + +export interface MatchParticipantSummary { + id: string; + firstName: string | null; + lastName: string | null; + email: string; + pronouns: string[] | null; + diagnosis: string | null; + age: number | null; + treatments: string[]; + experiences: string[]; + timezone: string | null; + lovedOneDiagnosis?: string | null; + lovedOneTreatments?: string[]; + lovedOneExperiences?: string[]; +} + +export interface MatchDetailForVolunteerResponse { + id: number; + participantId: string; + volunteerId: string; + participant: MatchParticipantSummary; + matchStatus: string; + createdAt: string; + updatedAt?: string | null; + chosenTimeBlock?: TimeBlockEntity | null; + suggestedTimeBlocks?: TimeBlockEntity[]; +} + +export interface MatchListForVolunteerResponse { + matches: MatchDetailForVolunteerResponse[]; +} + export const matchAPIClient = { /** * Create matches between a participant and volunteers @@ -34,4 +105,28 @@ export const matchAPIClient = { const response = await baseAPIClient.post('/matches/', request); return response.data; }, + + /** + * Get existing matches for a participant (admin only) + * @param participantId Participant user ID + * @returns List of participant's existing matches + */ + getMatchesForParticipant: async (participantId: string): Promise => { + const response = await baseAPIClient.get( + `/matches/participant/${participantId}`, + ); + return response.data; + }, + + /** + * Get existing matches for a volunteer (admin only) + * @param volunteerId Volunteer user ID + * @returns List of volunteer's existing matches + */ + getMatchesForVolunteer: async (volunteerId: string): Promise => { + const response = await baseAPIClient.get( + `/matches/volunteer/${volunteerId}`, + ); + return response.data; + }, }; diff --git a/frontend/src/APIClients/matchingAPIClient.ts b/frontend/src/APIClients/matchingAPIClient.ts index 9143bb5d..a8011d48 100644 --- a/frontend/src/APIClients/matchingAPIClient.ts +++ b/frontend/src/APIClients/matchingAPIClient.ts @@ -12,9 +12,21 @@ export interface AdminMatchCandidate { timezone: string | null; age: number | null; diagnosis: string | null; + dateOfDiagnosis?: string | null; treatments: string[]; experiences: string[]; matchScore: number; + matchCount: number; + // Additional fields for dynamic columns + maritalStatus?: string | null; + genderIdentity?: string | null; + ethnicGroup?: string[] | null; + hasKids?: string | null; + lovedOneAge?: string | null; + lovedOneDiagnosis?: string | null; + lovedOneDateOfDiagnosis?: string | null; + lovedOneTreatments?: string[]; + lovedOneExperiences?: string[]; } export interface AdminMatchesResponse { diff --git a/frontend/src/components/admin/submissionEditors/AdminRankingFormView.tsx b/frontend/src/components/admin/submissionEditors/AdminRankingFormView.tsx index 586d9b80..087b636a 100644 --- a/frontend/src/components/admin/submissionEditors/AdminRankingFormView.tsx +++ b/frontend/src/components/admin/submissionEditors/AdminRankingFormView.tsx @@ -55,6 +55,12 @@ export const AdminRankingFormView: React.FC = ({ const isDirtyRef = useRef(false); const latestFormDataRef = useRef(formData); + // Reset form data when initial answers change (e.g., after save/reload) + useEffect(() => { + setFormData(initialDataWithTarget); + setBaselineData(initialDataWithTarget); + }, [initialDataWithTarget]); + // Determine target (patient or caregiver) const rankingTarget = useMemo<'patient' | 'caregiver'>(() => { if (formData.target) { @@ -134,10 +140,15 @@ export const AdminRankingFormView: React.FC = ({ }) .sort((a, b) => a.rank - b.rank); + // Update both formData and baselineData to prevent false dirty detection setFormData((prev) => ({ ...prev, rankings: updatedRankings, })); + setBaselineData((prev) => ({ + ...prev, + rankings: updatedRankings, + })); } } catch { if (!isMounted) { diff --git a/frontend/src/components/admin/userProfile/MatchesContent.tsx b/frontend/src/components/admin/userProfile/MatchesContent.tsx index ddda78ac..fb38aa2f 100644 --- a/frontend/src/components/admin/userProfile/MatchesContent.tsx +++ b/frontend/src/components/admin/userProfile/MatchesContent.tsx @@ -1,12 +1,16 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useMemo } from 'react'; import { Box, Text, Spinner, VStack, HStack, Button, Flex, Badge } from '@chakra-ui/react'; -import { FiClock } from 'react-icons/fi'; +import { FiClock, FiHeart } from 'react-icons/fi'; +import Link from 'next/link'; import { matchingAPIClient, AdminMatchCandidate } from '@/APIClients/matchingAPIClient'; import { matchAPIClient } from '@/APIClients/matchAPIClient'; import { Checkbox } from '@/components/ui/checkbox'; import { useUserProfile } from '@/hooks/useUserProfile'; import { ProfileSummaryCard } from './ProfileSummaryCard'; import { NotesModal } from './NotesModal'; +import { rankingAPIClient, RankingPreference } from '@/APIClients/rankingAPIClient'; +import { SendMatchesSuccessModal } from './SendMatchesSuccessModal'; +import { SendMatchesConfirmationModal } from './SendMatchesConfirmationModal'; const scrollbarStyles = { '::-webkit-scrollbar': { @@ -28,30 +32,72 @@ const scrollbarStyles = { scrollbarColor: '#E0E0E0 #FAFAFA', }; +const LAYOUT_GUTTER = 8; + interface MatchesContentProps { participantId: string | string[] | undefined; } +type ColumnType = + | 'volunteer' + | 'timezone' + | 'age' + | 'maritalStatus' + | 'genderIdentity' + | 'ethnicGroup' + | 'parentalStatus' + | 'loAge' + | 'diagnosis' + | 'loDiagnosis' + | 'treatments' + | 'loTreatments' + | 'experiences' + | 'loExperiences' + | 'match'; + +interface ColumnConfig { + type: ColumnType; + label: string; + minWidth: string; + flex: string; + isLovedOne?: boolean; +} + export function MatchesContent({ participantId }: MatchesContentProps) { const [matches, setMatches] = useState([]); const [selectedVolunteerIds, setSelectedVolunteerIds] = useState>(new Set()); const [loading, setLoading] = useState(true); const [sending, setSending] = useState(false); const [error, setError] = useState(null); - const [success, setSuccess] = useState(null); - const { user, loading: userLoading } = useUserProfile(participantId); + const [preferences, setPreferences] = useState([]); + const [showConfirmationModal, setShowConfirmationModal] = useState(false); + const [showSuccessModal, setShowSuccessModal] = useState(false); + const [sentMatchCount, setSentMatchCount] = useState(0); + const { user } = useUserProfile(participantId); useEffect(() => { if (!participantId || typeof participantId !== 'string') { return; } - const fetchMatches = async () => { + const fetchData = async () => { try { setLoading(true); setError(null); - const response = await matchingAPIClient.getAdminMatches(participantId); - setMatches(response.matches); + + // Fetch matches and preferences in parallel + const [matchesResponse, preferencesResponse] = await Promise.all([ + matchingAPIClient.getAdminMatches(participantId), + rankingAPIClient + .getPreferences( + participantId, + user?.userData?.caringForSomeone === 'yes' ? 'caregiver' : 'patient', + ) + .catch(() => []), // If preferences fail, use empty array + ]); + + setMatches(matchesResponse.matches); + setPreferences(preferencesResponse); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load matches'); } finally { @@ -59,20 +105,22 @@ export function MatchesContent({ participantId }: MatchesContentProps) { } }; - fetchMatches(); - }, [participantId]); + fetchData(); + }, [participantId, user?.userData?.caringForSomeone]); - const handleSelectAll = (checked: boolean) => { - if (checked) { + const handleSelectAll = (details: { checked: boolean | string }) => { + const isChecked = details.checked === true || details.checked === 'checked'; + if (isChecked) { setSelectedVolunteerIds(new Set(matches.map((m) => m.volunteerId))); } else { setSelectedVolunteerIds(new Set()); } }; - const handleSelectVolunteer = (volunteerId: string, checked: boolean) => { + const handleSelectVolunteer = (volunteerId: string, details: { checked: boolean | string }) => { const newSelected = new Set(selectedVolunteerIds); - if (checked) { + const isChecked = details.checked === true || details.checked === 'checked'; + if (isChecked) { newSelected.add(volunteerId); } else { newSelected.delete(volunteerId); @@ -80,7 +128,14 @@ export function MatchesContent({ participantId }: MatchesContentProps) { setSelectedVolunteerIds(newSelected); }; - const handleSendMatches = async () => { + const handleSendMatchesClick = () => { + if (selectedVolunteerIds.size === 0) { + return; + } + setShowConfirmationModal(true); + }; + + const handleConfirmSendMatches = async () => { if (!participantId || typeof participantId !== 'string' || selectedVolunteerIds.size === 0) { return; } @@ -88,15 +143,16 @@ export function MatchesContent({ participantId }: MatchesContentProps) { try { setSending(true); setError(null); - setSuccess(null); + setShowConfirmationModal(false); await matchAPIClient.createMatches({ participantId, volunteerIds: Array.from(selectedVolunteerIds), }); - setSuccess(`Successfully created ${selectedVolunteerIds.size} match(es)`); + setSentMatchCount(selectedVolunteerIds.size); setSelectedVolunteerIds(new Set()); + setShowSuccessModal(true); // Optionally refresh matches to show updated status } catch (err) { setError(err instanceof Error ? err.message : 'Failed to create matches'); @@ -105,6 +161,149 @@ export function MatchesContent({ participantId }: MatchesContentProps) { } }; + const allSelected = matches.length > 0 && selectedVolunteerIds.size === matches.length; + + // Determine which columns to show based on preferences + const columns = useMemo((): ColumnConfig[] => { + const cols: ColumnConfig[] = [ + { type: 'volunteer', label: 'Volunteer', minWidth: '150px', flex: '0 0 150px' }, + { type: 'timezone', label: 'Time Zone', minWidth: '120px', flex: '0 0 120px' }, + { type: 'age', label: 'Age', minWidth: '100px', flex: '0 0 100px' }, + ]; + + // Add dynamic columns after Age based on preferences + const hasMaritalStatus = preferences.some( + (p) => p.kind === 'quality' && p.id === 4 && p.scope === 'self', + ); + const hasGenderIdentity = preferences.some( + (p) => p.kind === 'quality' && p.id === 2 && p.scope === 'self', + ); + const hasEthnicGroup = preferences.some( + (p) => p.kind === 'quality' && p.id === 3 && p.scope === 'self', + ); + const hasParentalStatus = preferences.some( + (p) => p.kind === 'quality' && p.id === 5 && p.scope === 'self', + ); + const hasLoAge = preferences.some( + (p) => p.kind === 'quality' && p.id === 1 && p.scope === 'loved_one', + ); + const hasLoDiagnosis = preferences.some( + (p) => p.kind === 'quality' && p.id === 6 && p.scope === 'loved_one', + ); + const hasLoTreatments = preferences.some( + (p) => p.kind === 'treatment' && p.scope === 'loved_one', + ); + const hasLoExperiences = preferences.some( + (p) => p.kind === 'experience' && p.scope === 'loved_one', + ); + + if (hasMaritalStatus) { + cols.push({ + type: 'maritalStatus', + label: 'Marital Status', + minWidth: '120px', + flex: '0 0 120px', + }); + } + if (hasGenderIdentity) { + cols.push({ type: 'genderIdentity', label: 'Gender', minWidth: '100px', flex: '0 0 100px' }); + } + if (hasEthnicGroup) { + cols.push({ + type: 'ethnicGroup', + label: 'Ethnic/Cultural Group', + minWidth: '180px', + flex: '0 0 180px', + }); + } + if (hasParentalStatus) { + cols.push({ + type: 'parentalStatus', + label: 'Parental Status', + minWidth: '120px', + flex: '0 0 120px', + }); + } + if (hasLoAge) { + cols.push({ + type: 'loAge', + label: 'LO: Age', + minWidth: '100px', + flex: '0 0 100px', + isLovedOne: true, + }); + } + + // Default columns after dynamic ones + cols.push({ type: 'diagnosis', label: 'Diagnosis', minWidth: '180px', flex: '0 0 180px' }); + + if (hasLoDiagnosis) { + cols.push({ + type: 'loDiagnosis', + label: 'LO: Diagnosis', + minWidth: '180px', + flex: '0 0 180px', + isLovedOne: true, + }); + } + + cols.push({ + type: 'treatments', + label: 'Treatment Info', + minWidth: '300px', + flex: '0 0 300px', + }); + + if (hasLoTreatments) { + cols.push({ + type: 'loTreatments', + label: 'LO: Treatment Info', + minWidth: '300px', + flex: '0 0 300px', + isLovedOne: true, + }); + } + + cols.push({ + type: 'experiences', + label: 'Experience Info', + minWidth: '250px', + flex: '0 0 250px', + }); + + if (hasLoExperiences) { + cols.push({ + type: 'loExperiences', + label: 'LO: Experience Info', + minWidth: '250px', + flex: '0 0 250px', + isLovedOne: true, + }); + } + + cols.push({ type: 'match', label: 'Match', minWidth: '150px', flex: '0 0 150px' }); + + return cols; + }, [preferences]); + + // Calculate table min-width dynamically based on columns + const tableMinWidth = useMemo(() => { + // Sum of all column widths + checkbox columns (16px each side) + padding (16px each side) + const checkboxWidth = 16; // Left checkbox + const rightCheckboxWidth = 16; // Right spacing + const paddingWidth = 16 * 2; // px={4} = 16px on each side + + const totalColumnWidth = columns.reduce((sum, col) => { + const width = parseInt(col.minWidth, 10); + return sum + width; + }, 0); + + const totalWidth = checkboxWidth + rightCheckboxWidth + totalColumnWidth + paddingWidth; + + // Ensure minimum width of 1200px for basic columns + return `${Math.max(totalWidth, 1200)}px`; + }, [columns]); + if (loading) { return ( @@ -124,24 +323,23 @@ export function MatchesContent({ participantId }: MatchesContentProps) { ); } - const allSelected = matches.length > 0 && selectedVolunteerIds.size === matches.length; - return ( {/* Cards Row - Above Table */} - + {/* Profile Summary Card - Left */} - + {/* Notes Modal - Right */} - + {/* Table Container - Below Cards */} - - {/* Scrollable Table Container */} - + + {/* Table Container */} + {/* Table Header */} - - Volunteer - - - Time Zone - - - Age - - - Diagnosis - - - Treatment Info - - - Experience Info - - - Match - + {columns.map((col) => ( + + {col.isLovedOne && } + + {col.label} + + + ))} @@ -232,17 +414,30 @@ export function MatchesContent({ participantId }: MatchesContentProps) { return { bg: '#FEF3F2', color: '#B42419' }; }; const scoreColors = getMatchScoreColor(match.matchScore); + const formatDate = (dateStr: string | null | undefined) => { + if (!dateStr) return null; + try { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + } catch { + return null; + } + }; return ( - + {index > 0 && } - handleSelectVolunteer(match.volunteerId, checked as boolean) + onCheckedChange={(details) => + handleSelectVolunteer(match.volunteerId, details) } size="sm" /> @@ -254,170 +449,287 @@ export function MatchesContent({ participantId }: MatchesContentProps) { align="flex-start" w="full" > - {/* Volunteer Name */} - - - {match.firstName} {match.lastName} - - - - {/* Time Zone */} - - - {match.timezone || 'N/A'} - - - - {/* Age */} - - - {match.age ?? 'N/A'} - - - - {/* Diagnosis */} - - - - {match.diagnosis || 'N/A'} - - - - - {/* Treatment Info */} - - {match.treatments.length > 0 ? ( - - {match.treatments.slice(0, 3).map((treatment, idx) => ( - - {treatment} - - ))} - {match.treatments.length > 3 && ( - - +{match.treatments.length - 3} - - )} - - ) : ( - - N/A - - )} - - - {/* Experience Info */} - - {match.experiences.length > 0 ? ( - - {match.experiences.slice(0, 2).map((experience, idx) => { - const truncatedExperience = - experience.length > 20 - ? `${experience.substring(0, 20)}...` - : experience; + {columns.map((col) => { + const renderCell = () => { + switch (col.type) { + case 'volunteer': + const getMatchCountBadgeColor = (count: number) => { + if (count >= 5) return { bg: '#FEF3F2', color: '#B42419' }; // red + if (count >= 3) return { bg: '#FEF0C7', color: '#DC6803' }; // yellow + return { bg: '#E7F8EE', color: '#027847' }; // green + }; + const badgeColors = getMatchCountBadgeColor(match.matchCount); + return ( + + + + {match.firstName} {match.lastName} + + + + + {match.matchCount} + + + ); + case 'timezone': + return ( + + {match.timezone || 'N/A'} + + ); + case 'age': + return ( + + {match.age ?? 'N/A'} + + ); + case 'maritalStatus': + return ( + + {match.maritalStatus || 'N/A'} + + ); + case 'genderIdentity': + return ( + + {match.genderIdentity || 'N/A'} + + ); + case 'ethnicGroup': return ( - - {truncatedExperience} - + + {match.ethnicGroup && match.ethnicGroup.length > 0 + ? match.ethnicGroup.join(', ') + : 'N/A'} + ); - })} - {match.experiences.length > 2 && ( - - +{match.experiences.length - 2} - - )} - - ) : ( - - N/A - - )} - - - {/* Match Score */} - - - - {match.matchScore.toFixed(0)} - - {/* Clock icon badge - placeholder for now */} - {match.matchScore >= 80 && ( - - - 2 - - )} - - + case 'parentalStatus': + return ( + + {match.hasKids === 'yes' + ? 'Has kids' + : match.hasKids === 'no' + ? 'No kids' + : 'N/A'} + + ); + case 'loAge': + return ( + + {match.lovedOneAge || 'N/A'} + + ); + case 'diagnosis': + return ( + + + {match.diagnosis || 'N/A'} + + {match.dateOfDiagnosis && ( + + {formatDate(match.dateOfDiagnosis)} + + )} + + ); + case 'loDiagnosis': + return ( + + + {match.lovedOneDiagnosis || 'N/A'} + + {match.lovedOneDateOfDiagnosis && ( + + {formatDate(match.lovedOneDateOfDiagnosis)} + + )} + + ); + case 'treatments': + return match.treatments.length > 0 ? ( + + {match.treatments.map((treatment, idx) => ( + + {treatment} + + ))} + + ) : ( + + N/A + + ); + case 'loTreatments': + return match.lovedOneTreatments && + match.lovedOneTreatments.length > 0 ? ( + + {match.lovedOneTreatments.map((treatment, idx) => ( + + {treatment} + + ))} + + ) : ( + + N/A + + ); + case 'experiences': + return match.experiences.length > 0 ? ( + + {match.experiences.map((experience, idx) => { + const truncatedExperience = + experience.length > 20 + ? `${experience.substring(0, 20)}...` + : experience; + return ( + + {truncatedExperience} + + ); + })} + + ) : ( + + N/A + + ); + case 'loExperiences': + return match.lovedOneExperiences && + match.lovedOneExperiences.length > 0 ? ( + + {match.lovedOneExperiences.map((experience, idx) => { + const truncatedExperience = + experience.length > 20 + ? `${experience.substring(0, 20)}...` + : experience; + return ( + + {truncatedExperience} + + ); + })} + + ) : ( + + N/A + + ); + case 'match': + return ( + + + {match.matchScore.toFixed(0)} + + {match.matchScore >= 80 && ( + + + 2 + + )} + + ); + default: + return null; + } + }; + + return ( + + {renderCell()} + + ); + })} @@ -429,26 +741,19 @@ export function MatchesContent({ participantId }: MatchesContentProps) { - {/* Error/Success Messages */} + {/* Error Messages */} {error && ( - + {error} )} - {success && ( - - - {success} - - - )} {/* Send Matches Button */} - + - + + + {/* Confirmation Modal */} + setShowConfirmationModal(false)} + onConfirm={handleConfirmSendMatches} + participantName={ + user?.userData + ? `${user.userData.firstName || ''} ${user.userData.lastName || ''}`.trim() || undefined + : undefined + } + matchCount={selectedVolunteerIds.size} + isSending={sending} + /> + + {/* Success Modal */} + setShowSuccessModal(false)} + participantName={ + user?.userData + ? `${user.userData.firstName || ''} ${user.userData.lastName || ''}`.trim() || undefined + : undefined + } + matchCount={sentMatchCount} + /> ); } diff --git a/frontend/src/components/admin/userProfile/NotesModal.tsx b/frontend/src/components/admin/userProfile/NotesModal.tsx index 0219f990..bdd35771 100644 --- a/frontend/src/components/admin/userProfile/NotesModal.tsx +++ b/frontend/src/components/admin/userProfile/NotesModal.tsx @@ -66,17 +66,25 @@ export function NotesModal({ participantId, participantName }: NotesModalProps) Submitted{' '} {latestTask.createdAt - ? new Date(latestTask.createdAt).toLocaleDateString('en-US', { - month: 'numeric', - day: 'numeric', - year: 'numeric', - }) + - ', ' + - new Date(latestTask.createdAt).toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }) + ? (() => { + // Backend sends UTC datetime without timezone marker, so we need to + // explicitly treat it as UTC before converting to local timezone + const utcDateString = latestTask.createdAt.endsWith('Z') + ? latestTask.createdAt + : `${latestTask.createdAt}Z`; + const date = new Date(utcDateString); + const dateStr = date.toLocaleDateString('en-US', { + month: 'numeric', + day: 'numeric', + year: 'numeric', + }); + const timeStr = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + return `${dateStr}, ${timeStr}`; + })() : 'N/A'} diff --git a/frontend/src/components/admin/userProfile/ProfileSummary.tsx b/frontend/src/components/admin/userProfile/ProfileSummary.tsx index 00380ce1..10c6cec0 100644 --- a/frontend/src/components/admin/userProfile/ProfileSummary.tsx +++ b/frontend/src/components/admin/userProfile/ProfileSummary.tsx @@ -48,9 +48,30 @@ const ETHNIC_OPTIONS = [ 'Another background/Prefer to self-describe (please specify):', ]; +const LANGUAGE_OPTIONS = ['English', 'Français']; + +// Helper functions to convert between display and DB values +const languageToDisplay = (dbValue: string | undefined): string => { + if (!dbValue) return 'English'; + if (dbValue.toLowerCase() === 'en' || dbValue.toLowerCase() === 'english') return 'English'; + if ( + dbValue.toLowerCase() === 'fr' || + dbValue.toLowerCase() === 'français' || + dbValue.toLowerCase() === 'francais' + ) + return 'Français'; + return dbValue; +}; + +const languageToDb = (displayValue: string): string => { + if (displayValue === 'Français') return 'fr'; + return 'en'; +}; + interface ProfileSummaryProps { userData: UserData | null | undefined; userEmail?: string; + userLanguage?: string; isEditing: boolean; isSaving: boolean; editData: ProfileEditData; @@ -63,6 +84,7 @@ interface ProfileSummaryProps { export function ProfileSummary({ userData, userEmail, + userLanguage, isEditing, isSaving, editData, @@ -289,9 +311,18 @@ export function ProfileSummary({ Preferred Language - - N/A - + {isEditing ? ( + onEditDataChange({ ...editData, language: value })} + placeholder="Select preferred language" + /> + ) : ( + + {languageToDisplay(userLanguage)} + + )} {/* Marital Status */} diff --git a/frontend/src/components/admin/userProfile/ProfileSummaryCard.tsx b/frontend/src/components/admin/userProfile/ProfileSummaryCard.tsx index 39604c7b..3d132b24 100644 --- a/frontend/src/components/admin/userProfile/ProfileSummaryCard.tsx +++ b/frontend/src/components/admin/userProfile/ProfileSummaryCard.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from 'react'; -import { Box, Text, VStack, HStack, Flex, Badge } from '@chakra-ui/react'; +import { Box, Text, VStack, HStack, Badge } from '@chakra-ui/react'; import { FiUser, FiHeart } from 'react-icons/fi'; import { UserData } from '@/types/userTypes'; import { rankingAPIClient, RankingPreference } from '@/APIClients/rankingAPIClient'; @@ -10,7 +10,7 @@ interface ProfileSummaryCardProps { userId?: string | string[] | undefined; } -export function ProfileSummaryCard({ userData, userEmail, userId }: ProfileSummaryCardProps) { +export function ProfileSummaryCard({ userData, userId }: ProfileSummaryCardProps) { const [preferences, setPreferences] = useState([]); const [loadingPreferences, setLoadingPreferences] = useState(false); @@ -55,6 +55,72 @@ export function ProfileSummaryCard({ userData, userEmail, userId }: ProfileSumma return { bg: '#EEF4FF', color: '#3538CD' }; }; + const getPreferenceDisplayName = (pref: RankingPreference): string => { + // For static qualities, show the actual value instead of generic label + if (pref.kind === 'quality') { + const scope = pref.scope; + const isLovedOne = scope === 'loved_one'; + + // Map quality IDs to userData fields + // Quality IDs: 1=same_age, 2=same_gender_identity, 3=same_ethnic_or_cultural_group, + // 4=same_marital_status, 5=same_parental_status, 6=same_diagnosis + if (pref.id === 1) { + // same_age + if (isLovedOne && userData?.lovedOneAge) { + return `LO: ${userData.lovedOneAge}`; + } else if (!isLovedOne && userData?.dateOfBirth) { + // Calculate age from date of birth + const birthDate = new Date(userData.dateOfBirth); + const today = new Date(); + let age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + return `${age}`; + } + } else if (pref.id === 2) { + // same_gender_identity + if (isLovedOne && userData?.lovedOneGenderIdentity) { + return `LO: ${userData.lovedOneGenderIdentity}`; + } else if (!isLovedOne && userData?.genderIdentity) { + return userData.genderIdentity; + } + } else if (pref.id === 3) { + // same_ethnic_or_cultural_group + if ( + userData?.ethnicGroup && + Array.isArray(userData.ethnicGroup) && + userData.ethnicGroup.length > 0 + ) { + return userData.ethnicGroup.join(', '); + } else if (userData?.ethnicGroup && typeof userData.ethnicGroup === 'string') { + return userData.ethnicGroup; + } + } else if (pref.id === 4) { + // same_marital_status + if (userData?.maritalStatus) { + return userData.maritalStatus; + } + } else if (pref.id === 5) { + // same_parental_status + if (userData?.hasKids) { + return userData.hasKids === 'yes' ? 'Has kids' : 'No kids'; + } + } else if (pref.id === 6) { + // same_diagnosis + if (isLovedOne && userData?.lovedOneDiagnosis) { + return `LO: ${userData.lovedOneDiagnosis}`; + } else if (!isLovedOne && userData?.diagnosis) { + return userData.diagnosis; + } + } + } + + // For dynamic options (treatments/experiences) or if we can't find the value, return the original name + return pref.name; + }; + return ( - - - Match with: Person with Cancer - + {/* Match with badge - show based on preferences and user data */} + {preferences.length > 0 && ( + + + Match with:{' '} + {userData?.caringForSomeone === 'yes' ? 'Caregiver' : 'Person with Cancer'} + + )} + {userData?.hasBloodCancer === 'yes' && ( + + Has Blood Cancer + + )} {userData?.caringForSomeone === 'yes' && ( {preferences.map((pref) => { const colors = getBadgeColor(pref.rank); - const scopePrefix = pref.scope === 'loved_one' ? 'LO: ' : ''; + const displayName = getPreferenceDisplayName(pref); + const isLovedOne = pref.scope === 'loved_one'; + // Only show LO: prefix if it's not already in the display name + const scopePrefix = isLovedOne && !displayName.startsWith('LO:') ? 'LO: ' : ''; return ( {pref.rank} + {isLovedOne && } {scopePrefix} - {pref.name} + {displayName} ); })} diff --git a/frontend/src/components/admin/userProfile/SendMatchesConfirmationModal.tsx b/frontend/src/components/admin/userProfile/SendMatchesConfirmationModal.tsx new file mode 100644 index 00000000..8f0ec00c --- /dev/null +++ b/frontend/src/components/admin/userProfile/SendMatchesConfirmationModal.tsx @@ -0,0 +1,142 @@ +import { Box, Button, Flex, Text, VStack } from '@chakra-ui/react'; +import { Icon } from '@chakra-ui/react'; +import { FiAlertCircle } from 'react-icons/fi'; + +interface SendMatchesConfirmationModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + participantName?: string; + matchCount: number; + isSending?: boolean; +} + +export function SendMatchesConfirmationModal({ + isOpen, + onClose, + onConfirm, + participantName, + matchCount, + isSending = false, +}: SendMatchesConfirmationModalProps) { + if (!isOpen) { + return null; + } + + return ( + + + + {/* Warning Icon */} + + + + + {/* Text Content */} + + + Are you sure you want to send these matches + {participantName ? ` to ${participantName}` : ''}? + + + Once submitted, {matchCount === 1 ? 'this match will' : 'these matches will'} be + shared + {participantName ? ` with ${participantName}` : ' with the participant'} for review. + Please ensure all selections are accurate. + + + + {/* Action Buttons */} + + + + + + + + ); +} diff --git a/frontend/src/components/admin/userProfile/SendMatchesSuccessModal.tsx b/frontend/src/components/admin/userProfile/SendMatchesSuccessModal.tsx new file mode 100644 index 00000000..a797460d --- /dev/null +++ b/frontend/src/components/admin/userProfile/SendMatchesSuccessModal.tsx @@ -0,0 +1,114 @@ +import { Box, Button, Flex, Text, VStack } from '@chakra-ui/react'; +import { Icon } from '@chakra-ui/react'; +import { FiCheckCircle } from 'react-icons/fi'; + +interface SendMatchesSuccessModalProps { + isOpen: boolean; + onClose: () => void; + participantName?: string; + matchCount: number; +} + +export function SendMatchesSuccessModal({ + isOpen, + onClose, + participantName, + matchCount, +}: SendMatchesSuccessModalProps) { + if (!isOpen) { + return null; + } + + return ( + + + + {/* Success Icon */} + + + + + {/* Text Content */} + + + Matches Sent! + + + {matchCount} {matchCount === 1 ? 'match has' : 'matches have'} been sent + {participantName ? ` to ${participantName}` : ''}. Matches sent, awaiting participant + response. + + + + {/* Action Button */} + + + + + + + ); +} diff --git a/frontend/src/components/matches/MatchStatusScreen.tsx b/frontend/src/components/matches/MatchStatusScreen.tsx new file mode 100644 index 00000000..9b5a79d1 --- /dev/null +++ b/frontend/src/components/matches/MatchStatusScreen.tsx @@ -0,0 +1,630 @@ +import React, { useState } from 'react'; +import { Box, Text, VStack, HStack, Badge, Button, Flex, Icon } from '@chakra-ui/react'; +import { FiChevronDown, FiChevronUp, FiUser, FiClock, FiActivity, FiHeart } from 'react-icons/fi'; +import { Match, MatchStatus, VolunteerSummary } from '@/types/matchTypes'; +import { UserRole } from '@/types/authTypes'; + +interface MatchStatusScreenProps { + matches: Match[] | VolunteerMatch[]; + userRole: UserRole; + userName?: string; +} + +// Volunteer match type (has participant instead of volunteer) +export interface ParticipantSummary { + id: string; + firstName: string | null; + lastName: string | null; + email: string; + pronouns: string[] | null; + diagnosis: string | null; + age: number | null; + timezone: string | null; + treatments: string[]; + experiences: string[]; + lovedOneDiagnosis?: string | null; + lovedOneTreatments?: string[]; + lovedOneExperiences?: string[]; +} + +export interface VolunteerMatch { + id: number; + participantId: string; + volunteerId: string; + participant: ParticipantSummary; + matchStatus: MatchStatus; + createdAt: string; + updatedAt: string | null; + chosenTimeBlock?: { id: number; startTime: string } | null; + suggestedTimeBlocks?: { id: number; startTime: string }[]; +} + +type DisplayStatus = + | 'match sent' + | 'availability sent' + | 'availability received' + | 'call scheduled'; + +interface MatchWithDisplayStatus { + id: number; + displayStatus: DisplayStatus; + displayDate: string | null; + displayTime: string | null; + person: VolunteerSummary | ParticipantSummary; + matchStatus: MatchStatus; +} + +export function MatchStatusScreen({ matches, userRole, userName }: MatchStatusScreenProps) { + const [expandedMatches, setExpandedMatches] = useState>(new Set()); + + const toggleMatchExpansion = (matchId: number) => { + const newExpanded = new Set(expandedMatches); + if (newExpanded.has(matchId)) { + newExpanded.delete(matchId); + } else { + newExpanded.add(matchId); + } + setExpandedMatches(newExpanded); + }; + + const getDisplayStatus = (match: Match | VolunteerMatch): DisplayStatus => { + const status = match.matchStatus.toLowerCase(); + + if (userRole === UserRole.VOLUNTEER) { + if (status === 'awaiting_volunteer_acceptance') { + return 'match sent'; + } else if (status === 'pending') { + return 'availability sent'; + } else if (status === 'confirmed') { + return 'call scheduled'; + } + } else { + // Participant + const participantMatch = match as Match; + if (status === 'pending') { + // If there are suggested time blocks, availability has been received + if ( + participantMatch.suggestedTimeBlocks && + participantMatch.suggestedTimeBlocks.length > 0 + ) { + return 'availability received'; + } + return 'match sent'; + } else if (status === 'confirmed') { + return 'call scheduled'; + } + } + + // Default fallback + return 'match sent'; + }; + + const formatDate = (dateStr: string | null | undefined): string | null => { + if (!dateStr) return null; + try { + const date = new Date(dateStr); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric', + }); + } catch { + return null; + } + }; + + const formatTime = (dateStr: string | null | undefined): string | null => { + if (!dateStr) return null; + try { + const date = new Date(dateStr); + return date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + } catch { + return null; + } + }; + + const processedMatches: MatchWithDisplayStatus[] = matches.map((match) => { + const displayStatus = getDisplayStatus(match); + + // Use match creation date and time + const displayDate = formatDate(match.createdAt); + const displayTime = formatTime(match.createdAt); + + // Get the person (volunteer for participants, participant for volunteers) + const person = + userRole === UserRole.VOLUNTEER + ? (match as VolunteerMatch).participant + : (match as Match).volunteer; + + return { + id: match.id, + displayStatus, + displayDate, + displayTime, + person, + matchStatus: match.matchStatus, + }; + }); + + // Show "Not Matched" state when there are no matches + if (processedMatches.length === 0) { + return ( + + + + + + Not Matched + + + {userName ? `${userName} has` : 'You have'} no active matches. + + + ); + } + + // Show "Currently Matched" state with matches list + return ( + + {/* Currently Matched Status Card */} + + + {/* Success Icon */} + + + + + + + + + + Currently Matched + + + {userName ? `${userName} has` : 'You have'} {processedMatches.length} active{' '} + {processedMatches.length === 1 ? 'match' : 'matches'}. + + + + + + {/* Matches Table */} + + {/* Table Header */} + + + + + + Name + + + + + + + + Date + + + + + + + Time + + + + + + + + {/* Table Body */} + + {processedMatches.map((match, index) => { + const isExpanded = expandedMatches.has(match.id); + const person = match.person; + + const fullName = person + ? `${person.firstName || ''} ${person.lastName || ''}`.trim() || person.email + : 'Unknown'; + + const pronounsText = + person?.pronouns && person.pronouns.length > 0 ? person.pronouns.join('/') : ''; + + return ( + + {index > 0 && } + + {/* Main Row */} + + + + + + {fullName} + + {pronounsText && ( + + {pronounsText} + + )} + + + + + {match.displayDate || '-'} + + + + + {match.displayTime || '-'} + + + + + {match.displayStatus} + + + + + + + + + {/* Expanded Details */} + {isExpanded && ( + + {(() => { + // Track which treatments/experiences are from loved one + const regularTreatments = person?.treatments || []; + const lovedOneTreatments = person?.lovedOneTreatments || []; + const regularExperiences = person?.experiences || []; + const lovedOneExperiences = person?.lovedOneExperiences || []; + + return ( + + {/* Overview */} + + + Overview + + + {typeof person?.age === 'number' && ( + + + Current Age: {person.age} + + )} + {person?.timezone && ( + + + Time Zone: {person.timezone} + + )} + {person?.diagnosis && ( + + + {person.diagnosis} + + )} + {person?.lovedOneDiagnosis && ( + + + Loved One: {person.lovedOneDiagnosis} + + )} + {!person?.age && + !person?.timezone && + !person?.diagnosis && + !person?.lovedOneDiagnosis && ( + + No overview information available + + )} + + + + {/* Treatment Information */} + + + Treatment Information + + + {regularTreatments.length > 0 || lovedOneTreatments.length > 0 ? ( + <> + {regularTreatments.map((treatment: string, idx: number) => ( + + {treatment} + + ))} + {lovedOneTreatments.map((treatment: string, idx: number) => ( + + + {treatment} + + ))} + + ) : ( + + No treatment information available + + )} + + + + {/* Experience Information */} + + + Experience Information + + + {regularExperiences.length > 0 || lovedOneExperiences.length > 0 ? ( + <> + {regularExperiences.map((experience: string, idx: number) => ( + + {experience} + + ))} + {lovedOneExperiences.map((experience: string, idx: number) => ( + + + {experience} + + ))} + + ) : ( + + No experience information available + + )} + + + + ); + })()} + + )} + + + ); + })} + + + + ); +} diff --git a/frontend/src/hooks/useProfileEditing.ts b/frontend/src/hooks/useProfileEditing.ts index 7da2e722..88a9ad67 100644 --- a/frontend/src/hooks/useProfileEditing.ts +++ b/frontend/src/hooks/useProfileEditing.ts @@ -30,6 +30,25 @@ export function useProfileEditing({ const userData = user?.userData; + // Helper to convert language from DB to display format + const languageToDisplay = (dbValue: string | undefined): string => { + if (!dbValue) return 'English'; + if (dbValue.toLowerCase() === 'en' || dbValue.toLowerCase() === 'english') return 'English'; + if ( + dbValue.toLowerCase() === 'fr' || + dbValue.toLowerCase() === 'français' || + dbValue.toLowerCase() === 'francais' + ) + return 'Français'; + return dbValue; + }; + + // Helper to convert language from display to DB format + const languageToDb = (displayValue: string): string => { + if (displayValue === 'Français') return 'fr'; + return 'en'; + }; + const handleStartEditProfileSummary = () => { if (userData) { setProfileEditData({ @@ -45,6 +64,7 @@ export function useProfileEditing({ hasKids: userData.hasKids || '', lovedOneGenderIdentity: userData.lovedOneGenderIdentity || '', lovedOneAge: userData.lovedOneAge || '', + language: languageToDisplay(user?.language), }); } setIsEditingProfileSummary(true); @@ -68,6 +88,7 @@ export function useProfileEditing({ hasKids: profileEditData.hasKids, lovedOneGenderIdentity: profileEditData.lovedOneGenderIdentity, lovedOneAge: profileEditData.lovedOneAge, + language: profileEditData.language ? languageToDb(profileEditData.language) : undefined, }); setUser(updatedUser); diff --git a/frontend/src/pages/admin/directory.tsx b/frontend/src/pages/admin/directory.tsx index 4b6a8ec0..09b8c05f 100644 --- a/frontend/src/pages/admin/directory.tsx +++ b/frontend/src/pages/admin/directory.tsx @@ -62,7 +62,11 @@ const DIRECTORY_COLORS = { applyButtonBg: '#056067', // Teal from Figma } as const; -const formStatusMap: Record = { +// Base form status map (without completed status which depends on match count) +const baseFormStatusMap: Record< + Exclude, + { status: string; label: string; progress: number } +> = { 'intake-todo': { // when participant/volunteer has made an account and is in progress of completing intake form status: 'Not started', @@ -84,10 +88,10 @@ const formStatusMap: Record { + // For completed status, check match count + if (formStatus === 'completed') { + if (matchCount === 0) { + // User is completed but has no matches - they're in matching phase + return { + status: 'In-progress', + label: 'Matching', + progress: 75, + }; + } else { + // User has at least one match - they're matched + return { + status: 'Completed', + label: 'Matched', + progress: 100, + }; + } + } + + // For all other statuses, use the base map + return ( + baseFormStatusMap[formStatus as Exclude] || { + status: 'Not started', + label: 'Intake form', + progress: 0, + } + ); +}; + const getStatusColor = (step: string): { bg: string; color: string } => { const lowerStep = step.toLowerCase(); if (lowerStep.includes('rejected')) @@ -201,6 +233,8 @@ export default function Directory() { appliedStatusFilters.matched || appliedStatusFilters.rejected; const userFormStatus = user.formStatus as FormStatus | undefined; + const userMatchCount = user.matchCount ?? 0; + const matchesStatus = !hasStatusFilter || (appliedStatusFilters.intakeForm && userFormStatus === FormStatus.INTAKE_TODO) || @@ -211,8 +245,11 @@ export default function Directory() { userFormStatus === FormStatus.SECONDARY_APPLICATION_TODO) || (appliedStatusFilters.matching && (userFormStatus === FormStatus.RANKING_SUBMITTED || - userFormStatus === FormStatus.SECONDARY_APPLICATION_SUBMITTED)) || - (appliedStatusFilters.matched && userFormStatus === FormStatus.COMPLETED) || + userFormStatus === FormStatus.SECONDARY_APPLICATION_SUBMITTED || + (userFormStatus === FormStatus.COMPLETED && userMatchCount === 0))) || + (appliedStatusFilters.matched && + userFormStatus === FormStatus.COMPLETED && + userMatchCount > 0) || (appliedStatusFilters.rejected && userFormStatus === FormStatus.REJECTED); return matchesSearch && matchesUserType && matchesStatus; @@ -228,9 +265,11 @@ export default function Directory() { return sortBy === 'nameAsc' ? comparison : -comparison; } else { // Sort by status (using progress values) - const progressA = formStatusMap[a.formStatus as FormStatus]?.progress ?? 0; - const progressB = formStatusMap[b.formStatus as FormStatus]?.progress ?? 0; - const comparison = progressA - progressB; + const matchCountA = a.matchCount ?? 0; + const matchCountB = b.matchCount ?? 0; + const statusConfigA = getFormStatusConfig(a.formStatus as FormStatus, matchCountA); + const statusConfigB = getFormStatusConfig(b.formStatus as FormStatus, matchCountB); + const comparison = statusConfigA.progress - statusConfigB.progress; return sortBy === 'statusAsc' ? comparison : -comparison; } }); @@ -502,15 +541,24 @@ export default function Directory() { - + {(() => { + const userMatchCount = user.matchCount ?? 0; + const statusConfig = getFormStatusConfig( + user.formStatus as FormStatus, + userMatchCount, + ); + return ; + })()} {(() => { - const statusConfig = formStatusMap[user.formStatus as FormStatus]; - const statusLabel = statusConfig?.label ?? 'Intake form'; - const statusLevel = statusConfig?.status ?? 'Not started'; + const userMatchCount = user.matchCount ?? 0; + const statusConfig = getFormStatusConfig( + user.formStatus as FormStatus, + userMatchCount, + ); + const statusLabel = statusConfig.label; + const statusLevel = statusConfig.status; const statusColors = getStatusColor(statusLevel); return ( ; - const updated = await intakeAPIClient.updateFormSubmission(submission.id, sanitizedAnswers); - setSubmission(updated); + await intakeAPIClient.updateFormSubmission(submission.id, sanitizedAnswers); setEditSuccess('Form saved successfully.'); + // Reload submission to get updated data and reset dirty state + await loadSubmission(submission.id); } catch (err: unknown) { const message = err instanceof Error ? err.message : 'Failed to save form.'; setEditError(message); diff --git a/frontend/src/pages/admin/users/[id]/index.tsx b/frontend/src/pages/admin/users/[id]/index.tsx index a7ffe381..b6cb6ec0 100644 --- a/frontend/src/pages/admin/users/[id]/index.tsx +++ b/frontend/src/pages/admin/users/[id]/index.tsx @@ -15,8 +15,17 @@ import { ProfileNavigation } from '@/components/admin/userProfile/ProfileNavigat import { SuccessMessage } from '@/components/admin/userProfile/SuccessMessage'; import { ProfileSummary } from '@/components/admin/userProfile/ProfileSummary'; import { ProfileContent } from '@/components/admin/userProfile/ProfileContent'; +import { MatchesContent } from '@/components/admin/userProfile/MatchesContent'; +import { MatchStatusScreen } from '@/components/matches/MatchStatusScreen'; import { SaveMessage } from '@/types/userProfileTypes'; import { intakeAPIClient, FormSubmission } from '@/APIClients/intakeAPIClient'; +import { + matchAPIClient, + MatchDetailResponse, + MatchDetailForVolunteerResponse, +} from '@/APIClients/matchAPIClient'; +import { Match } from '@/types/matchTypes'; +import { VolunteerMatch } from '@/components/matches/MatchStatusScreen'; const statusColors = { pending_approval: { @@ -63,6 +72,9 @@ export default function AdminUserProfile() { const [formsLoading, setFormsLoading] = useState(true); const [formsError, setFormsError] = useState(null); const [creatingFormId, setCreatingFormId] = useState(null); + const [existingMatchesCount, setExistingMatchesCount] = useState(0); + const [existingMatches, setExistingMatches] = useState([]); + const [matchesLoading, setMatchesLoading] = useState(false); // Custom hooks const { user, loading, setUser } = useUserProfile(id); @@ -133,6 +145,115 @@ export default function AdminUserProfile() { void loadUserForms(); }, [loadUserForms]); + const loadExistingMatches = useCallback(async () => { + if (!id || typeof id !== 'string' || !user) { + return; + } + + try { + setMatchesLoading(true); + const currentRole = roleIdToUserRole(user.roleId); + + if (currentRole === UserRole.VOLUNTEER) { + // Fetch volunteer matches + const response = await matchAPIClient.getMatchesForVolunteer(id); + setExistingMatchesCount(response.matches.length); + + // Transform MatchDetailForVolunteerResponse[] to VolunteerMatch[] format + const transformedMatches: VolunteerMatch[] = response.matches.map( + (match: MatchDetailForVolunteerResponse) => ({ + id: match.id, + participantId: match.participantId, + volunteerId: match.volunteerId, + participant: { + id: match.participant.id, + firstName: match.participant.firstName, + lastName: match.participant.lastName, + email: match.participant.email, + pronouns: match.participant.pronouns || null, + diagnosis: match.participant.diagnosis || null, + age: match.participant.age || null, + timezone: match.participant.timezone || null, + treatments: match.participant.treatments || [], + experiences: match.participant.experiences || [], + lovedOneDiagnosis: match.participant.lovedOneDiagnosis || null, + lovedOneTreatments: match.participant.lovedOneTreatments || [], + lovedOneExperiences: match.participant.lovedOneExperiences || [], + }, + matchStatus: match.matchStatus as Match['matchStatus'], + createdAt: match.createdAt, + updatedAt: match.updatedAt || null, + chosenTimeBlock: match.chosenTimeBlock + ? { + id: match.chosenTimeBlock.id, + startTime: match.chosenTimeBlock.startTime, + } + : null, + suggestedTimeBlocks: match.suggestedTimeBlocks || [], + }), + ); + + setExistingMatches(transformedMatches); + } else { + // Fetch participant matches + const response = await matchAPIClient.getMatchesForParticipant(id); + setExistingMatchesCount(response.matches.length); + + // Transform MatchDetailResponse[] to Match[] format for MatchStatusScreen + const transformedMatches: Match[] = response.matches.map((match: MatchDetailResponse) => ({ + id: match.id, + participantId: match.participantId, + volunteer: { + id: match.volunteer.id, + firstName: match.volunteer.firstName, + lastName: match.volunteer.lastName, + email: match.volunteer.email, + phone: match.volunteer.phone || null, + pronouns: match.volunteer.pronouns || null, + diagnosis: match.volunteer.diagnosis || null, + age: match.volunteer.age || null, + timezone: match.volunteer.timezone || null, + treatments: match.volunteer.treatments || [], + experiences: match.volunteer.experiences || [], + overview: match.volunteer.overview || null, + lovedOneDiagnosis: match.volunteer.lovedOneDiagnosis || null, + lovedOneTreatments: match.volunteer.lovedOneTreatments || [], + lovedOneExperiences: match.volunteer.lovedOneExperiences || [], + }, + matchStatus: match.matchStatus as Match['matchStatus'], + chosenTimeBlock: match.chosenTimeBlock + ? { + id: match.chosenTimeBlock.id, + startTime: match.chosenTimeBlock.startTime, + } + : null, + suggestedTimeBlocks: match.suggestedTimeBlocks.map((tb) => ({ + id: tb.id, + startTime: tb.startTime, + })), + createdAt: match.createdAt, + updatedAt: match.updatedAt || null, + })); + + setExistingMatches(transformedMatches); + } + } catch (error) { + console.error('[loadExistingMatches] Failed to fetch existing matches:', error); + setExistingMatchesCount(0); + setExistingMatches([]); + } finally { + setMatchesLoading(false); + } + }, [id, user]); + + const activeTab = (router.query.tab as string) || 'profile'; + + useEffect(() => { + if (activeTab === 'matches') { + void loadExistingMatches(); + } + }, [activeTab, loadExistingMatches]); + const role = user ? roleIdToUserRole(user.roleId) : null; const formStatus = user?.formStatus; @@ -197,7 +318,7 @@ export default function AdminUserProfile() { showCreateButton: formStatus === 'completed', }, ]; - }, [role]); + }, [role, formStatus]); const groupedForms = useMemo(() => { const grouped: Record = {}; @@ -305,9 +426,6 @@ export default function AdminUserProfile() { const userData = user.userData; const volunteerData = user.volunteerData; - // Determine active tab based on route or query param - const activeTab = (router.query.tab as string) || 'profile'; - const handleTabChange = (tab: string) => { router.push({ pathname: router.pathname, query: { ...router.query, tab } }, undefined, { shallow: true, @@ -368,6 +486,7 @@ export default function AdminUserProfile() { ) : activeTab === 'matches' ? ( - - Matches content coming soon... + + {matchesLoading ? ( + + + + Loading matches... + + + ) : user && + role === UserRole.PARTICIPANT && + user.pendingVolunteerRequest && + existingMatchesCount === 0 ? ( + + ) : ( + + + Matches + + + + )} ) : null} diff --git a/frontend/src/pages/participant/dashboard.tsx b/frontend/src/pages/participant/dashboard.tsx index f328ad29..8b035b1a 100644 --- a/frontend/src/pages/participant/dashboard.tsx +++ b/frontend/src/pages/participant/dashboard.tsx @@ -29,11 +29,13 @@ import { participantMatchAPIClient } from '@/APIClients/participantMatchAPIClien import { getCurrentUser } from '@/APIClients/authAPIClient'; import { AuthenticatedUser, FormStatus, UserRole } from '@/types/authTypes'; import { Match } from '@/types/matchTypes'; +import { MatchStatusScreen } from '@/components/matches/MatchStatusScreen'; export default function ParticipantDashboardPage() { const router = useRouter(); const [matches, setMatches] = useState([]); const [confirmedMatches, setConfirmedMatches] = useState([]); + const [allMatches, setAllMatches] = useState([]); const [hasPendingRequest, setHasPendingRequest] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -77,6 +79,9 @@ export default function ParticipantDashboardPage() { setMatches(pendingMatches); setConfirmedMatches(confirmed); setHasPendingRequest(data.hasPendingRequest || false); + + // Store all matches for status screen + setAllMatches(data.matches); } catch (err) { console.error('Error loading matches:', err); const errorMessage = @@ -217,7 +222,7 @@ export default function ParticipantDashboardPage() { // Show request new matches screen when there are no active matches // This includes: completed matches, cancelled matches, or brand new users - if (confirmedMatches.length === 0 && matches.length === 0) { + if (allMatches.length === 0) { return ( {/* Additional Notes Section */} @@ -271,27 +276,14 @@ export default function ParticipantDashboardPage() { ); } - // Show confirmed matches (upcoming calls) - if (confirmedMatches.length > 0) { - return ( - - {confirmedMatches.map((match) => ( - - ))} - - ); - } - + // Show status screen with all matches return ( - {matches.map((match) => ( - - ))} + {/* Request New Matches Button */}