From 33a2288ed0536768b17757d8d7fa175f3fa2ae5c Mon Sep 17 00:00:00 2001 From: FrancoRu Date: Fri, 18 Sep 2026 13:38:48 -0300 Subject: [PATCH] Sube Webclient/src --- Club12-WebClient/src/App.css | 3 + Club12-WebClient/src/App.test.tsx | 79 + Club12-WebClient/src/App.tsx | 362 +++++ .../src/design/categoryColor.test.ts | 22 + Club12-WebClient/src/design/categoryColor.ts | 27 + Club12-WebClient/src/design/colorName.test.ts | 47 + Club12-WebClient/src/design/colorName.ts | 88 ++ .../src/design/jerseyStyles.test.ts | 52 + Club12-WebClient/src/design/jerseyStyles.ts | 81 + Club12-WebClient/src/design/tokens.ts | 123 ++ .../src/g-loot-react-tournament-brackets.d.ts | 12 + Club12-WebClient/src/index.css | 37 + Club12-WebClient/src/main.tsx | 64 + .../auditLog/context/auditLog.context.tsx | 51 + .../modules/auditLog/hook/auditLog.hook.ts | 10 + .../src/modules/auditLog/queryKeys.ts | 8 + .../auditLog/service/auditLog.service.ts | 27 + .../src/modules/auditLog/type/auditLog.d.ts | 76 + .../auth/context/auth.context.test.tsx | 60 + .../src/modules/auth/context/auth.context.tsx | 335 +++++ .../src/modules/auth/hook/auth.hook.ts | 10 + .../src/modules/auth/queryKeys.test.ts | 8 + .../src/modules/auth/queryKeys.ts | 3 + .../modules/auth/service/auth.service.test.ts | 51 + .../src/modules/auth/service/auth.service.ts | 59 + .../src/modules/auth/type/auth.d.ts | 235 +++ .../src/modules/auth/utils/passwordPolicy.ts | 95 ++ .../modules/backup/hook/backup.hook.test.ts | 250 ++++ .../src/modules/backup/hook/backup.hook.ts | 99 ++ .../modules/backup/service/backup.service.ts | 51 + .../src/modules/backup/type/backup.d.ts | 62 + .../src/modules/backup/utils/backupFormat.ts | 34 + .../modules/blogPost/constants/blogPost.ts | 7 + .../context/blogPost.context.test.tsx | 53 + .../blogPost/context/blogPost.context.tsx | 207 +++ .../modules/blogPost/hook/blogPost.hook.ts | 10 + .../src/modules/blogPost/queryKeys.test.ts | 24 + .../src/modules/blogPost/queryKeys.ts | 9 + .../blogPost/service/blogPost.service.ts | 120 ++ .../src/modules/blogPost/type/blogPost.d.ts | 226 +++ .../champion/service/champion.service.ts | 36 + .../src/modules/champion/type/champion.d.ts | 52 + .../champion/utils/groupChampions.test.ts | 115 ++ .../modules/champion/utils/groupChampions.ts | 99 ++ .../src/modules/club/context/club.context.tsx | 219 +++ .../src/modules/club/hook/club.hook.ts | 10 + .../src/modules/club/queryKeys.ts | 4 + .../src/modules/club/service/club.service.ts | 87 ++ .../src/modules/club/type/club.d.ts | 162 ++ .../src/modules/core/constants/appRoutes.ts | 124 ++ .../src/modules/core/constants/constants.ts | 42 + .../modules/core/constants/dataGridLocale.ts | 27 + .../src/modules/core/constants/httpStatus.ts | 18 + .../src/modules/core/constants/order.ts | 4 + .../src/modules/core/constants/pagination.ts | 23 + .../src/modules/core/constants/routes.test.ts | 12 + .../src/modules/core/constants/routes.ts | 32 + .../modules/core/enum/match/matchStatus.ts | 34 + .../src/modules/core/enum/match/matchType.ts | 17 + .../enum/medicalRecord/medicalRecordStatus.ts | 29 + .../enum/tournament/tournamentCategory.ts | 22 + .../core/enum/tournament/tournamentStatus.ts | 11 + .../modules/core/enum/user/userRolesType.ts | 22 + .../src/modules/core/types/types.d.ts | 43 + .../src/modules/core/utils/axiosUtils.test.ts | 174 +++ .../src/modules/core/utils/axiosUtils.ts | 349 +++++ .../src/modules/core/utils/comparator.ts | 52 + .../src/modules/core/utils/confirmDialog.ts | 117 ++ .../src/modules/core/utils/csv.test.ts | 86 ++ .../src/modules/core/utils/csv.ts | 134 ++ .../src/modules/core/utils/formUtils.ts | 5 + .../src/modules/core/utils/formatDate.test.ts | 75 + .../src/modules/core/utils/formatDate.ts | 176 +++ .../src/modules/core/utils/geocoding.test.ts | 60 + .../src/modules/core/utils/geocoding.ts | 44 + .../core/utils/maintenanceBanner.test.ts | 112 ++ .../modules/core/utils/maintenanceBanner.ts | 57 + .../modules/core/utils/pageMetadata.test.ts | 124 ++ .../src/modules/core/utils/pageMetadata.ts | 182 +++ .../src/modules/core/utils/printStyles.ts | 31 + .../src/modules/core/utils/problemDetails.ts | 28 + .../core/utils/requestActivity.test.ts | 100 ++ .../src/modules/core/utils/requestActivity.ts | 87 ++ .../modules/core/utils/synchronizeStates.ts | 17 + .../modules/core/utils/translateStageType.ts | 20 + .../src/modules/core/utils/validators.test.ts | 136 ++ .../src/modules/core/utils/validators.ts | 143 ++ .../service/dataMaintenance.service.ts | 28 + .../dataMaintenance/type/dataMaintenance.d.ts | 26 + .../context/division.context.test.tsx | 83 ++ .../division/context/division.context.tsx | 410 ++++++ .../modules/division/hook/division.hook.ts | 10 + .../src/modules/division/queryKeys.test.ts | 21 + .../src/modules/division/queryKeys.ts | 10 + .../division/service/division.service.ts | 170 +++ .../src/modules/division/type/division.d.ts | 543 +++++++ .../utils/divisionStructureSummary.test.ts | 89 ++ .../utils/divisionStructureSummary.ts | 43 + .../division/utils/qualificationRange.test.ts | 78 + .../division/utils/qualificationRange.ts | 76 + .../modules/division/utils/sortPositions.ts | 17 + .../error/context/error.context.test.tsx | 86 ++ .../modules/error/context/error.context.tsx | 121 ++ .../src/modules/error/hooks/error.hock.ts | 10 + .../error/hooks/useUnknownErrorHandler.ts | 25 + .../src/modules/error/type/error.d.ts | 13 + .../error/utils/extractErrorMessage.ts | 57 + .../match/context/match.context.test.tsx | 130 ++ .../modules/match/context/match.context.tsx | 381 +++++ .../src/modules/match/hook/match.hook.ts | 10 + .../src/modules/match/queryKeys.test.ts | 21 + .../src/modules/match/queryKeys.ts | 10 + .../match/service/match.service.test.ts | 82 ++ .../modules/match/service/match.service.ts | 156 ++ .../src/modules/match/type/match.d.ts | 513 +++++++ .../utils/divisionFixtureSections.test.ts | 148 ++ .../match/utils/divisionFixtureSections.ts | 117 ++ .../modules/match/utils/matchDisplay.test.ts | 104 ++ .../src/modules/match/utils/matchDisplay.ts | 120 ++ .../modules/match/utils/matchFixtureCsv.ts | 34 + .../modules/match/utils/matchGrouping.test.ts | 104 ++ .../src/modules/match/utils/matchGrouping.ts | 82 ++ .../service/matchSeries.service.ts | 57 + .../modules/matchSeries/type/matchSeries.d.ts | 53 + .../utils/groupMatchesBySeries.test.ts | 103 ++ .../matchSeries/utils/groupMatchesBySeries.ts | 50 + .../context/medicalRecord.context.tsx | 134 ++ .../medicalRecord/hook/medicalRecord.hook.ts | 12 + .../modules/medicalRecord/queryKeys.test.ts | 21 + .../src/modules/medicalRecord/queryKeys.ts | 11 + .../service/medicalRecord.service.ts | 93 ++ .../medicalRecord/type/medicalRecord.d.ts | 93 ++ .../utils/medicalRecordDisplay.test.ts | 79 + .../utils/medicalRecordDisplay.ts | 82 ++ .../modules/player/context/player.context.tsx | 246 ++++ .../src/modules/player/hook/player.hook.ts | 10 + .../src/modules/player/queryKeys.test.ts | 26 + .../src/modules/player/queryKeys.ts | 12 + .../modules/player/service/player.service.ts | 93 ++ .../src/modules/player/type/player.d.ts | 344 +++++ .../player/utils/rosterConflict.test.ts | 62 + .../modules/player/utils/rosterConflict.ts | 62 + .../context/playerSanction.context.tsx | 267 ++++ .../hook/playerSanction.hook.ts | 12 + .../modules/playerSanction/queryKeys.test.ts | 25 + .../src/modules/playerSanction/queryKeys.ts | 9 + .../service/playerSanction.service.ts | 86 ++ .../playerSanction/type/playerSanction.d.ts | 432 ++++++ .../utils/sanctionDisplay.test.ts | 92 ++ .../playerSanction/utils/sanctionDisplay.ts | 75 + .../context/playerStatistic.context.tsx | 271 ++++ .../hook/playerStatistic.hook.ts | 12 + .../modules/playerStatistic/queryKeys.test.ts | 27 + .../src/modules/playerStatistic/queryKeys.ts | 10 + .../service/playerStatistic.service.test.ts | 36 + .../service/playerStatistic.service.ts | 109 ++ .../playerStatistic/type/playerStatistic.d.ts | 325 ++++ .../utils/playerStatisticDisplay.ts | 11 + .../modules/playoff/bracketAdapter.test.ts | 360 +++++ .../src/modules/playoff/bracketAdapter.ts | 222 +++ .../playoff/bracketMatchNavigation.test.ts | 107 ++ .../modules/playoff/bracketMatchNavigation.ts | 42 + .../src/modules/playoff/buildBracket.test.ts | 614 ++++++++ .../src/modules/playoff/buildBracket.ts | 316 ++++ .../src/modules/playoff/matchStatus.ts | 180 +++ .../modules/playoff/templateBracket.test.ts | 81 + .../src/modules/playoff/templateBracket.ts | 82 ++ .../src/modules/playoff/type/bracket.d.ts | 65 + .../playoff/type/gLootBracketTypes.d.ts | 17 + .../hook/pointDeduction.hook.test.ts | 101 ++ .../hook/pointDeduction.hook.ts | 84 ++ .../service/pointDeduction.service.ts | 49 + .../pointDeduction/type/pointDeduction.d.ts | 46 + .../modules/scorer/context/scorer.context.tsx | 102 ++ .../src/modules/scorer/hook/scorer.hook.ts | 10 + .../src/modules/scorer/queryKeys.test.ts | 19 + .../src/modules/scorer/queryKeys.ts | 8 + .../modules/scorer/service/scorer.service.ts | 31 + .../src/modules/scorer/type/scorer.d.ts | 77 + .../modules/scorer/utils/scorerScope.test.ts | 60 + .../src/modules/scorer/utils/scorerScope.ts | 53 + .../season/context/season.context.test.tsx | 83 ++ .../modules/season/context/season.context.tsx | 235 +++ .../src/modules/season/hook/season.hook.ts | 10 + .../src/modules/season/queryKeys.ts | 7 + .../modules/season/service/season.service.ts | 71 + .../src/modules/season/type/season.d.ts | 131 ++ .../modules/stage/context/stage.context.tsx | 311 ++++ .../src/modules/stage/hook/stage.hook.ts | 10 + .../src/modules/stage/queryKeys.test.ts | 21 + .../src/modules/stage/queryKeys.ts | 9 + .../modules/stage/service/stage.service.ts | 136 ++ .../src/modules/stage/type/stage.ts | 454 ++++++ .../src/modules/stage/utils/stageLabel.ts | 7 + .../src/modules/team/context/team.context.tsx | 221 +++ .../src/modules/team/hook/team.hook.ts | 10 + .../src/modules/team/hook/useTeamProfile.ts | 183 +++ .../src/modules/team/queryKeys.test.ts | 21 + .../src/modules/team/queryKeys.ts | 9 + .../src/modules/team/service/team.service.ts | 162 ++ .../src/modules/team/type/team.d.ts | 304 ++++ .../src/modules/team/type/teamProfile.d.ts | 75 + .../modules/team/utils/teamProfile.test.ts | 196 +++ .../src/modules/team/utils/teamProfile.ts | 93 ++ .../teamStaff/hook/teamStaff.hook.test.ts | 114 ++ .../modules/teamStaff/hook/teamStaff.hook.ts | 84 ++ .../teamStaff/service/teamStaff.service.ts | 52 + .../src/modules/teamStaff/type/teamStaff.d.ts | 53 + .../teamStaff/utils/teamStaffDisplay.ts | 7 + .../context/tournament.context.test.tsx | 127 ++ .../tournament/context/tournament.context.tsx | 375 +++++ .../tournament/hook/tournament.hook.ts | 12 + .../tournament/service/tournament.service.ts | 172 +++ .../tournament/type/createFullTournament.d.ts | 126 ++ .../modules/tournament/type/tournament.d.ts | 483 ++++++ .../utils/completabilityMessages.test.ts | 95 ++ .../utils/completabilityMessages.ts | 34 + .../tournament/utils/tournamentDisplay.ts | 43 + .../utils/tournamentStatusTransitions.test.ts | 153 ++ .../utils/tournamentStatusTransitions.ts | 59 + .../src/modules/user/context/user.context.tsx | 216 +++ .../src/modules/user/hook/user.hook.ts | 10 + .../src/modules/user/queryKeys.test.ts | 21 + .../src/modules/user/queryKeys.ts | 10 + .../src/modules/user/service/user.service.ts | 57 + .../src/modules/user/type/user.d.ts | 78 + .../venue/context/venue.context.test.tsx | 139 ++ .../modules/venue/context/venue.context.tsx | 259 ++++ .../src/modules/venue/hook/venue.hook.ts | 10 + .../src/modules/venue/queryKeys.test.ts | 15 + .../src/modules/venue/queryKeys.ts | 4 + .../modules/venue/service/venue.service.ts | 103 ++ .../src/modules/venue/type/venue.d.ts | 181 +++ Club12-WebClient/src/mui-data-grid.d.ts | 15 + Club12-WebClient/src/test/setup.ts | 12 + Club12-WebClient/src/test/smoke.test.tsx | 11 + .../src/theme.color-tokens.test.ts | 20 + Club12-WebClient/src/theme.ts | 328 +++++ .../views/auth/PasswordPolicyChecklist.tsx | 34 + .../src/views/auth/activateAccount.tsx | 149 ++ .../src/views/auth/forgotPassword.tsx | 128 ++ Club12-WebClient/src/views/auth/login.tsx | 105 ++ .../src/views/auth/passwordReset.tsx | 142 ++ .../src/views/blogPost/BlogListPage.tsx | 32 + .../blogPost/BlogPostDetailPage.test.tsx | 332 +++++ .../src/views/blogPost/BlogPostDetailPage.tsx | 200 +++ .../views/blogPost/BlogPostEditPage.test.tsx | 222 +++ .../src/views/blogPost/BlogPostEditPage.tsx | 259 ++++ .../blogPost/BlogPostImageField.test.tsx | 65 + .../src/views/blogPost/BlogPostImageField.tsx | 93 ++ .../blogPost/BlogPostPreviewDialog.test.tsx | 55 + .../views/blogPost/BlogPostPreviewDialog.tsx | 66 + .../src/views/blogPost/BlogPostsPage.test.tsx | 128 ++ .../src/views/blogPost/BlogPostsPage.tsx | 253 ++++ .../views/blogPost/addBlogPostForm.test.tsx | 231 +++ .../src/views/blogPost/addBlogPostForm.tsx | 191 +++ .../src/views/blogPost/showPosts.test.tsx | 95 ++ .../src/views/blogPost/showPosts.tsx | 211 +++ .../src/views/champion/Podium.test.tsx | 85 ++ .../src/views/champion/Podium.tsx | 160 ++ .../src/views/club/ClubHistoryPage.test.tsx | 370 +++++ .../src/views/club/ClubHistoryPage.tsx | 431 ++++++ .../src/views/club/ClubsPage.test.tsx | 135 ++ Club12-WebClient/src/views/club/ClubsPage.tsx | 280 ++++ .../src/views/core/MUI/icons/icons.tsx | 121 ++ .../components/BasketballCourtPattern.tsx | 102 ++ .../views/core/components/BlockingOverlay.tsx | 34 + .../core/components/CategoryChip.test.tsx | 18 + .../views/core/components/CategoryChip.tsx | 31 + .../core/components/ComposeProviders.tsx | 14 + .../core/components/DivisionStagePicker.tsx | 106 ++ .../core/components/ErrorPageActions.tsx | 51 + .../views/core/components/ErrorPageLayout.tsx | 51 + .../views/core/components/ExportCsvButton.tsx | 34 + .../core/components/FieldInfoTooltip.tsx | 23 + .../views/core/components/FilterBar.test.tsx | 55 + .../src/views/core/components/FilterBar.tsx | 55 + .../src/views/core/components/FormButtons.tsx | 45 + .../components/GlobalLoadingOverlay.test.tsx | 66 + .../core/components/GlobalLoadingOverlay.tsx | 36 + .../views/core/components/JerseySvg.test.tsx | 124 ++ .../src/views/core/components/JerseySvg.tsx | 342 +++++ .../src/views/core/components/LeafletMap.tsx | 131 ++ .../core/components/LoadErrorState.test.tsx | 31 + .../views/core/components/LoadErrorState.tsx | 43 + .../core/components/LoadingIndicator.tsx | 17 + .../views/core/components/NewEntityButton.tsx | 69 + .../views/core/components/PageShell.test.tsx | 52 + .../src/views/core/components/PageShell.tsx | 100 ++ .../views/core/components/PublicLayout.tsx | 21 + .../views/core/components/QueryProvider.tsx | 10 + .../src/views/core/components/ScrollToTop.tsx | 19 + .../views/core/components/SecondaryTabs.tsx | 60 + .../views/core/components/SectionHeading.tsx | 64 + .../core/components/SidebarLayout.test.tsx | 90 ++ .../views/core/components/SidebarLayout.tsx | 472 ++++++ .../views/core/components/StatTile.test.tsx | 20 + .../src/views/core/components/StatTile.tsx | 90 ++ .../views/core/components/TableRowActions.tsx | 65 + .../views/core/components/TableScrollBox.tsx | 36 + .../core/components/TeamBackdrop.test.tsx | 25 + .../views/core/components/TeamBackdrop.tsx | 82 ++ .../views/core/components/TeamHero.test.tsx | 40 + .../src/views/core/components/TeamHero.tsx | 135 ++ .../src/views/core/components/TeamLogo.tsx | 38 + .../views/core/components/ThemedProvider.tsx | 15 + .../core/components/buildActionsColumn.tsx | 40 + .../components/skeletons/CardGridSkeleton.tsx | 34 + .../components/skeletons/DetailSkeleton.tsx | 30 + .../components/skeletons/ListSkeleton.tsx | 29 + .../components/skeletons/TableSkeleton.tsx | 48 + .../views/core/components/skeletons/index.ts | 7 + .../components/skeletons/skeletons.test.tsx | 35 + .../src/views/core/constants/const.ts | 10 + .../src/views/core/errors/NotFound.tsx | 22 + .../views/core/errors/error-boundary.test.tsx | 48 + .../src/views/core/errors/error-boundary.tsx | 81 + .../src/views/core/errors/forbidden.tsx | 22 + .../src/views/core/errors/invalidToken.tsx | 22 + .../src/views/core/privateRoute.tsx | 33 + .../src/views/core/routes-const.ts | 22 + .../views/division/DivisionFixture.test.tsx | 74 + .../src/views/division/DivisionFixture.tsx | 218 +++ .../division/DivisionFormatSection.test.tsx | 153 ++ .../views/division/DivisionFormatSection.tsx | 165 +++ .../division/DivisionScorersTable.test.tsx | 84 ++ .../views/division/DivisionScorersTable.tsx | 250 ++++ .../views/division/PointDeductionManager.tsx | 259 ++++ .../views/division/PrintableResultsSheet.tsx | 162 ++ .../division/divisionCreatePage.test.tsx | 114 ++ .../src/views/division/divisionCreatePage.tsx | 211 +++ .../views/division/divisionEditPage.test.tsx | 86 ++ .../src/views/division/divisionEditPage.tsx | 187 +++ .../src/views/division/divisionPage.test.tsx | 215 +++ .../src/views/division/divisionPage.tsx | 547 +++++++ .../views/division/divisionStandings.test.tsx | 49 + .../src/views/division/divisionStandings.tsx | 233 +++ .../src/views/division/divisionsPage.test.tsx | 72 + .../src/views/division/divisionsPage.tsx | 349 +++++ .../src/views/home/Footer/Footer.tsx | 99 ++ .../src/views/home/NavMenu/desktop.tsx | 165 +++ .../src/views/home/NavMenu/mobile.tsx | 131 ++ .../src/views/home/NavMenu/navMenu.tsx | 81 + .../champions/PublicChampionsPage.test.tsx | 128 ++ .../home/champions/PublicChampionsPage.tsx | 337 +++++ Club12-WebClient/src/views/home/home.test.tsx | 93 ++ Club12-WebClient/src/views/home/home.tsx | 517 +++++++ .../src/views/home/howWeAre/howWeAre.tsx | 60 + .../views/home/information/medicalRecord.tsx | 104 ++ .../src/views/home/information/regulation.tsx | 99 ++ .../home/information/regulationContent.ts | 345 +++++ .../home/matches/MatchFixtureList.test.tsx | 366 +++++ .../views/home/matches/MatchFixtureList.tsx | 287 ++++ .../src/views/home/matches/MatchRow.test.tsx | 130 ++ .../src/views/home/matches/MatchRow.tsx | 160 ++ .../home/matches/PublicMatchPage.test.tsx | 223 +++ .../views/home/matches/PublicMatchPage.tsx | 355 +++++ .../views/home/matches/SeriesCard.test.tsx | 139 ++ .../src/views/home/matches/SeriesCard.tsx | 182 +++ .../home/sanctions/PublicSanctionsPage.tsx | 309 ++++ .../home/seasons/PublicSeasonPage.test.tsx | 64 + .../views/home/seasons/PublicSeasonPage.tsx | 228 +++ .../home/seasons/PublicSeasonsPage.test.tsx | 61 + .../views/home/seasons/PublicSeasonsPage.tsx | 142 ++ .../views/home/teams/PublicTeamPage.test.tsx | 354 +++++ .../src/views/home/teams/PublicTeamPage.tsx | 606 ++++++++ .../home/teams/TeamStaffSection.test.tsx | 46 + .../src/views/home/teams/TeamStaffSection.tsx | 48 + .../tournaments/PublicDivisionPanel.test.tsx | 395 +++++ .../home/tournaments/PublicDivisionPanel.tsx | 338 +++++ .../views/home/tournaments/PublicTeamGrid.tsx | 70 + .../tournaments/PublicTournamentPage.test.tsx | 233 +++ .../home/tournaments/PublicTournamentPage.tsx | 410 ++++++ .../views/match/MatchStatisticsTab.test.tsx | 231 +++ .../src/views/match/MatchStatisticsTab.tsx | 392 +++++ .../src/views/match/MatchStatusChip.test.tsx | 35 + .../src/views/match/MatchStatusChip.tsx | 53 + .../views/match/StageMatchesByRound.test.tsx | 71 + .../src/views/match/StageMatchesByRound.tsx | 251 ++++ .../src/views/match/matchPage.tsx | 678 +++++++++ .../medicalRecord/HabilitacionBadge.test.tsx | 41 + .../views/medicalRecord/HabilitacionBadge.tsx | 47 + .../PlayerMedicalRecordDialog.test.tsx | 365 +++++ .../PlayerMedicalRecordDialog.tsx | 377 +++++ .../src/views/panel/AuditLogsPage.test.tsx | 108 ++ .../src/views/panel/AuditLogsPage.tsx | 278 ++++ .../src/views/panel/ChangePasswordPage.tsx | 5 + .../panel/DataAdministrationPage.test.tsx | 145 ++ .../views/panel/DataAdministrationPage.tsx | 163 ++ .../src/views/panel/StatisticsPage.test.tsx | 247 ++++ .../src/views/panel/StatisticsPage.tsx | 620 ++++++++ .../src/views/panel/UsersPage.tsx | 358 +++++ .../panel/components/BackupsTable.test.tsx | 204 +++ .../views/panel/components/BackupsTable.tsx | 127 ++ .../src/views/panel/statisticsFilters.test.ts | 111 ++ .../src/views/panel/statisticsFilters.ts | 75 + .../src/views/player/PlayerFormDialog.tsx | 74 + .../src/views/player/PlayerFormFields.tsx | 162 ++ .../src/views/player/PlayerPage.test.tsx | 206 +++ .../src/views/player/PlayerPage.tsx | 528 +++++++ .../src/views/player/PlayersPage.test.tsx | 319 ++++ .../src/views/player/PlayersPage.tsx | 962 ++++++++++++ .../src/views/player/players.types.ts | 117 ++ .../PlayerSanctionPage.test.tsx | 72 + .../playerSanction/PlayerSanctionPage.tsx | 692 +++++++++ .../PlayerSanctionsPage.test.tsx | 70 + .../playerSanction/PlayerSanctionsPage.tsx | 614 ++++++++ .../SanctionSubjectFields.test.tsx | 84 ++ .../playerSanction/SanctionSubjectFields.tsx | 134 ++ .../playerSanctionCreatePage.tsx | 538 +++++++ .../playerSanctionDeletePage.tsx | 72 + .../playerSanctionEditPage.test.tsx | 60 + .../playerSanction/playerSanctionEditPage.tsx | 213 +++ .../playerStatistic/PlayerHistory.test.tsx | 69 + .../views/playerStatistic/PlayerHistory.tsx | 111 ++ .../PlayerStatisticCard.test.tsx | 52 + .../playerStatistic/PlayerStatisticCard.tsx | 94 ++ .../playerStatisticCreatePage.tsx | 458 ++++++ .../BracketMatchLibraryAdapter.test.tsx | 62 + .../playoff/BracketMatchLibraryAdapter.tsx | 51 + .../views/playoff/BracketMatchNode.test.tsx | 229 +++ .../src/views/playoff/BracketMatchNode.tsx | 214 +++ .../src/views/playoff/PlayoffBracket.test.tsx | 337 +++++ .../src/views/playoff/PlayoffBracket.tsx | 211 +++ .../src/views/playoff/PlayoffCups.test.tsx | 155 ++ .../src/views/playoff/PlayoffCups.tsx | 167 +++ .../views/playoff/PlayoffDrawDialog.test.tsx | 213 +++ .../src/views/playoff/PlayoffDrawDialog.tsx | 290 ++++ .../playoff/SeriesInProgressPanel.test.tsx | 154 ++ .../views/playoff/SeriesInProgressPanel.tsx | 167 +++ .../src/views/playoff/playoffBracketTheme.ts | 90 ++ .../season/AdminSeasonDetailPage.test.tsx | 257 ++++ .../views/season/AdminSeasonDetailPage.tsx | 330 +++++ .../src/views/season/SeasonFormDialog.tsx | 73 + .../src/views/season/SeasonsPage.test.tsx | 105 ++ .../src/views/season/SeasonsPage.tsx | 328 +++++ .../views/team/RosterCsvImportDialog.test.tsx | 171 +++ .../src/views/team/RosterCsvImportDialog.tsx | 307 ++++ .../src/views/team/TeamFormDialog.test.tsx | 145 ++ .../src/views/team/TeamFormDialog.tsx | 59 + .../src/views/team/TeamFormFields.tsx | 369 +++++ .../src/views/team/TeamPage.test.tsx | 197 +++ Club12-WebClient/src/views/team/TeamPage.tsx | 594 ++++++++ .../src/views/team/TeamStaffManager.test.tsx | 184 +++ .../src/views/team/TeamStaffManager.tsx | 222 +++ .../src/views/team/TeamsFilterBar.test.tsx | 52 + .../src/views/team/TeamsFilterBar.tsx | 70 + .../src/views/team/TeamsPage.test.tsx | 406 +++++ Club12-WebClient/src/views/team/TeamsPage.tsx | 419 ++++++ .../src/views/team/TeamsTable.test.tsx | 147 ++ .../src/views/team/TeamsTable.tsx | 46 + .../src/views/team/teams.types.ts | 31 + .../src/views/tournament/EnrollTeamDialog.tsx | 278 ++++ .../TournamentDivisionAssignment.test.tsx | 892 +++++++++++ .../TournamentDivisionAssignment.tsx | 1306 +++++++++++++++++ .../views/tournament/TournamentEditPage.tsx | 396 +++++ .../TournamentEnrolledTeams.test.tsx | 401 +++++ .../tournament/TournamentEnrolledTeams.tsx | 189 +++ .../views/tournament/TournamentPage.test.tsx | 272 ++++ .../src/views/tournament/TournamentPage.tsx | 443 ++++++ .../wizard/TournamentWizardPage.test.tsx | 78 + .../wizard/TournamentWizardPage.tsx | 216 +++ .../tournament/wizard/TreeNodeList.test.tsx | 28 + .../views/tournament/wizard/TreeNodeList.tsx | 46 + .../tournament/wizard/cloneWizard.test.ts | 294 ++++ .../views/tournament/wizard/cloneWizard.ts | 254 ++++ .../wizard/steps/CopaCruzadaStep.test.tsx | 60 + .../wizard/steps/CopaCruzadaStep.tsx | 152 ++ .../tournament/wizard/steps/CupsEditor.tsx | 181 +++ .../wizard/steps/DivisionesStep.tsx | 48 + .../tournament/wizard/steps/RevisionStep.tsx | 45 + .../tournament/wizard/steps/TorneoStep.tsx | 143 ++ .../tournament/wizard/steps/ZoneEditor.tsx | 152 ++ .../tournament/wizard/submitWizard.test.ts | 481 ++++++ .../views/tournament/wizard/submitWizard.ts | 335 +++++ .../src/views/tournament/wizard/types.ts | 260 ++++ .../tournament/wizard/wizardLogic.test.ts | 336 +++++ .../views/tournament/wizard/wizardLogic.ts | 347 +++++ .../src/views/user/createUser.test.tsx | 45 + .../src/views/user/createUser.tsx | 220 +++ .../src/views/user/editUser.test.tsx | 63 + Club12-WebClient/src/views/user/editUser.tsx | 329 +++++ .../src/views/user/inviteUser.tsx | 193 +++ .../src/views/user/updatePassword.tsx | 338 +++++ .../src/views/user/userDetails.tsx | 120 ++ .../src/views/venue/VenueFormDialog.tsx | 59 + .../src/views/venue/VenueFormFields.test.tsx | 124 ++ .../src/views/venue/VenueFormFields.tsx | 211 +++ .../src/views/venue/VenuePage.test.tsx | 199 +++ .../src/views/venue/VenuesPage.test.tsx | 96 ++ .../src/views/venue/VenuesPage.tsx | 375 +++++ .../src/views/venue/venuePage.tsx | 250 ++++ .../src/views/venue/venues.types.ts | 18 + Club12-WebClient/src/vite-env.d.ts | 1 + 494 files changed, 69573 insertions(+) create mode 100644 Club12-WebClient/src/App.css create mode 100644 Club12-WebClient/src/App.test.tsx create mode 100644 Club12-WebClient/src/App.tsx create mode 100644 Club12-WebClient/src/design/categoryColor.test.ts create mode 100644 Club12-WebClient/src/design/categoryColor.ts create mode 100644 Club12-WebClient/src/design/colorName.test.ts create mode 100644 Club12-WebClient/src/design/colorName.ts create mode 100644 Club12-WebClient/src/design/jerseyStyles.test.ts create mode 100644 Club12-WebClient/src/design/jerseyStyles.ts create mode 100644 Club12-WebClient/src/design/tokens.ts create mode 100644 Club12-WebClient/src/g-loot-react-tournament-brackets.d.ts create mode 100644 Club12-WebClient/src/index.css create mode 100644 Club12-WebClient/src/main.tsx create mode 100644 Club12-WebClient/src/modules/auditLog/context/auditLog.context.tsx create mode 100644 Club12-WebClient/src/modules/auditLog/hook/auditLog.hook.ts create mode 100644 Club12-WebClient/src/modules/auditLog/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/auditLog/service/auditLog.service.ts create mode 100644 Club12-WebClient/src/modules/auditLog/type/auditLog.d.ts create mode 100644 Club12-WebClient/src/modules/auth/context/auth.context.test.tsx create mode 100644 Club12-WebClient/src/modules/auth/context/auth.context.tsx create mode 100644 Club12-WebClient/src/modules/auth/hook/auth.hook.ts create mode 100644 Club12-WebClient/src/modules/auth/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/auth/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/auth/service/auth.service.test.ts create mode 100644 Club12-WebClient/src/modules/auth/service/auth.service.ts create mode 100644 Club12-WebClient/src/modules/auth/type/auth.d.ts create mode 100644 Club12-WebClient/src/modules/auth/utils/passwordPolicy.ts create mode 100644 Club12-WebClient/src/modules/backup/hook/backup.hook.test.ts create mode 100644 Club12-WebClient/src/modules/backup/hook/backup.hook.ts create mode 100644 Club12-WebClient/src/modules/backup/service/backup.service.ts create mode 100644 Club12-WebClient/src/modules/backup/type/backup.d.ts create mode 100644 Club12-WebClient/src/modules/backup/utils/backupFormat.ts create mode 100644 Club12-WebClient/src/modules/blogPost/constants/blogPost.ts create mode 100644 Club12-WebClient/src/modules/blogPost/context/blogPost.context.test.tsx create mode 100644 Club12-WebClient/src/modules/blogPost/context/blogPost.context.tsx create mode 100644 Club12-WebClient/src/modules/blogPost/hook/blogPost.hook.ts create mode 100644 Club12-WebClient/src/modules/blogPost/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/blogPost/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/blogPost/service/blogPost.service.ts create mode 100644 Club12-WebClient/src/modules/blogPost/type/blogPost.d.ts create mode 100644 Club12-WebClient/src/modules/champion/service/champion.service.ts create mode 100644 Club12-WebClient/src/modules/champion/type/champion.d.ts create mode 100644 Club12-WebClient/src/modules/champion/utils/groupChampions.test.ts create mode 100644 Club12-WebClient/src/modules/champion/utils/groupChampions.ts create mode 100644 Club12-WebClient/src/modules/club/context/club.context.tsx create mode 100644 Club12-WebClient/src/modules/club/hook/club.hook.ts create mode 100644 Club12-WebClient/src/modules/club/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/club/service/club.service.ts create mode 100644 Club12-WebClient/src/modules/club/type/club.d.ts create mode 100644 Club12-WebClient/src/modules/core/constants/appRoutes.ts create mode 100644 Club12-WebClient/src/modules/core/constants/constants.ts create mode 100644 Club12-WebClient/src/modules/core/constants/dataGridLocale.ts create mode 100644 Club12-WebClient/src/modules/core/constants/httpStatus.ts create mode 100644 Club12-WebClient/src/modules/core/constants/order.ts create mode 100644 Club12-WebClient/src/modules/core/constants/pagination.ts create mode 100644 Club12-WebClient/src/modules/core/constants/routes.test.ts create mode 100644 Club12-WebClient/src/modules/core/constants/routes.ts create mode 100644 Club12-WebClient/src/modules/core/enum/match/matchStatus.ts create mode 100644 Club12-WebClient/src/modules/core/enum/match/matchType.ts create mode 100644 Club12-WebClient/src/modules/core/enum/medicalRecord/medicalRecordStatus.ts create mode 100644 Club12-WebClient/src/modules/core/enum/tournament/tournamentCategory.ts create mode 100644 Club12-WebClient/src/modules/core/enum/tournament/tournamentStatus.ts create mode 100644 Club12-WebClient/src/modules/core/enum/user/userRolesType.ts create mode 100644 Club12-WebClient/src/modules/core/types/types.d.ts create mode 100644 Club12-WebClient/src/modules/core/utils/axiosUtils.test.ts create mode 100644 Club12-WebClient/src/modules/core/utils/axiosUtils.ts create mode 100644 Club12-WebClient/src/modules/core/utils/comparator.ts create mode 100644 Club12-WebClient/src/modules/core/utils/confirmDialog.ts create mode 100644 Club12-WebClient/src/modules/core/utils/csv.test.ts create mode 100644 Club12-WebClient/src/modules/core/utils/csv.ts create mode 100644 Club12-WebClient/src/modules/core/utils/formUtils.ts create mode 100644 Club12-WebClient/src/modules/core/utils/formatDate.test.ts create mode 100644 Club12-WebClient/src/modules/core/utils/formatDate.ts create mode 100644 Club12-WebClient/src/modules/core/utils/geocoding.test.ts create mode 100644 Club12-WebClient/src/modules/core/utils/geocoding.ts create mode 100644 Club12-WebClient/src/modules/core/utils/maintenanceBanner.test.ts create mode 100644 Club12-WebClient/src/modules/core/utils/maintenanceBanner.ts create mode 100644 Club12-WebClient/src/modules/core/utils/pageMetadata.test.ts create mode 100644 Club12-WebClient/src/modules/core/utils/pageMetadata.ts create mode 100644 Club12-WebClient/src/modules/core/utils/printStyles.ts create mode 100644 Club12-WebClient/src/modules/core/utils/problemDetails.ts create mode 100644 Club12-WebClient/src/modules/core/utils/requestActivity.test.ts create mode 100644 Club12-WebClient/src/modules/core/utils/requestActivity.ts create mode 100644 Club12-WebClient/src/modules/core/utils/synchronizeStates.ts create mode 100644 Club12-WebClient/src/modules/core/utils/translateStageType.ts create mode 100644 Club12-WebClient/src/modules/core/utils/validators.test.ts create mode 100644 Club12-WebClient/src/modules/core/utils/validators.ts create mode 100644 Club12-WebClient/src/modules/dataMaintenance/service/dataMaintenance.service.ts create mode 100644 Club12-WebClient/src/modules/dataMaintenance/type/dataMaintenance.d.ts create mode 100644 Club12-WebClient/src/modules/division/context/division.context.test.tsx create mode 100644 Club12-WebClient/src/modules/division/context/division.context.tsx create mode 100644 Club12-WebClient/src/modules/division/hook/division.hook.ts create mode 100644 Club12-WebClient/src/modules/division/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/division/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/division/service/division.service.ts create mode 100644 Club12-WebClient/src/modules/division/type/division.d.ts create mode 100644 Club12-WebClient/src/modules/division/utils/divisionStructureSummary.test.ts create mode 100644 Club12-WebClient/src/modules/division/utils/divisionStructureSummary.ts create mode 100644 Club12-WebClient/src/modules/division/utils/qualificationRange.test.ts create mode 100644 Club12-WebClient/src/modules/division/utils/qualificationRange.ts create mode 100644 Club12-WebClient/src/modules/division/utils/sortPositions.ts create mode 100644 Club12-WebClient/src/modules/error/context/error.context.test.tsx create mode 100644 Club12-WebClient/src/modules/error/context/error.context.tsx create mode 100644 Club12-WebClient/src/modules/error/hooks/error.hock.ts create mode 100644 Club12-WebClient/src/modules/error/hooks/useUnknownErrorHandler.ts create mode 100644 Club12-WebClient/src/modules/error/type/error.d.ts create mode 100644 Club12-WebClient/src/modules/error/utils/extractErrorMessage.ts create mode 100644 Club12-WebClient/src/modules/match/context/match.context.test.tsx create mode 100644 Club12-WebClient/src/modules/match/context/match.context.tsx create mode 100644 Club12-WebClient/src/modules/match/hook/match.hook.ts create mode 100644 Club12-WebClient/src/modules/match/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/match/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/match/service/match.service.test.ts create mode 100644 Club12-WebClient/src/modules/match/service/match.service.ts create mode 100644 Club12-WebClient/src/modules/match/type/match.d.ts create mode 100644 Club12-WebClient/src/modules/match/utils/divisionFixtureSections.test.ts create mode 100644 Club12-WebClient/src/modules/match/utils/divisionFixtureSections.ts create mode 100644 Club12-WebClient/src/modules/match/utils/matchDisplay.test.ts create mode 100644 Club12-WebClient/src/modules/match/utils/matchDisplay.ts create mode 100644 Club12-WebClient/src/modules/match/utils/matchFixtureCsv.ts create mode 100644 Club12-WebClient/src/modules/match/utils/matchGrouping.test.ts create mode 100644 Club12-WebClient/src/modules/match/utils/matchGrouping.ts create mode 100644 Club12-WebClient/src/modules/matchSeries/service/matchSeries.service.ts create mode 100644 Club12-WebClient/src/modules/matchSeries/type/matchSeries.d.ts create mode 100644 Club12-WebClient/src/modules/matchSeries/utils/groupMatchesBySeries.test.ts create mode 100644 Club12-WebClient/src/modules/matchSeries/utils/groupMatchesBySeries.ts create mode 100644 Club12-WebClient/src/modules/medicalRecord/context/medicalRecord.context.tsx create mode 100644 Club12-WebClient/src/modules/medicalRecord/hook/medicalRecord.hook.ts create mode 100644 Club12-WebClient/src/modules/medicalRecord/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/medicalRecord/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/medicalRecord/service/medicalRecord.service.ts create mode 100644 Club12-WebClient/src/modules/medicalRecord/type/medicalRecord.d.ts create mode 100644 Club12-WebClient/src/modules/medicalRecord/utils/medicalRecordDisplay.test.ts create mode 100644 Club12-WebClient/src/modules/medicalRecord/utils/medicalRecordDisplay.ts create mode 100644 Club12-WebClient/src/modules/player/context/player.context.tsx create mode 100644 Club12-WebClient/src/modules/player/hook/player.hook.ts create mode 100644 Club12-WebClient/src/modules/player/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/player/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/player/service/player.service.ts create mode 100644 Club12-WebClient/src/modules/player/type/player.d.ts create mode 100644 Club12-WebClient/src/modules/player/utils/rosterConflict.test.ts create mode 100644 Club12-WebClient/src/modules/player/utils/rosterConflict.ts create mode 100644 Club12-WebClient/src/modules/playerSanction/context/playerSanction.context.tsx create mode 100644 Club12-WebClient/src/modules/playerSanction/hook/playerSanction.hook.ts create mode 100644 Club12-WebClient/src/modules/playerSanction/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/playerSanction/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/playerSanction/service/playerSanction.service.ts create mode 100644 Club12-WebClient/src/modules/playerSanction/type/playerSanction.d.ts create mode 100644 Club12-WebClient/src/modules/playerSanction/utils/sanctionDisplay.test.ts create mode 100644 Club12-WebClient/src/modules/playerSanction/utils/sanctionDisplay.ts create mode 100644 Club12-WebClient/src/modules/playerStatistic/context/playerStatistic.context.tsx create mode 100644 Club12-WebClient/src/modules/playerStatistic/hook/playerStatistic.hook.ts create mode 100644 Club12-WebClient/src/modules/playerStatistic/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/playerStatistic/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/playerStatistic/service/playerStatistic.service.test.ts create mode 100644 Club12-WebClient/src/modules/playerStatistic/service/playerStatistic.service.ts create mode 100644 Club12-WebClient/src/modules/playerStatistic/type/playerStatistic.d.ts create mode 100644 Club12-WebClient/src/modules/playerStatistic/utils/playerStatisticDisplay.ts create mode 100644 Club12-WebClient/src/modules/playoff/bracketAdapter.test.ts create mode 100644 Club12-WebClient/src/modules/playoff/bracketAdapter.ts create mode 100644 Club12-WebClient/src/modules/playoff/bracketMatchNavigation.test.ts create mode 100644 Club12-WebClient/src/modules/playoff/bracketMatchNavigation.ts create mode 100644 Club12-WebClient/src/modules/playoff/buildBracket.test.ts create mode 100644 Club12-WebClient/src/modules/playoff/buildBracket.ts create mode 100644 Club12-WebClient/src/modules/playoff/matchStatus.ts create mode 100644 Club12-WebClient/src/modules/playoff/templateBracket.test.ts create mode 100644 Club12-WebClient/src/modules/playoff/templateBracket.ts create mode 100644 Club12-WebClient/src/modules/playoff/type/bracket.d.ts create mode 100644 Club12-WebClient/src/modules/playoff/type/gLootBracketTypes.d.ts create mode 100644 Club12-WebClient/src/modules/pointDeduction/hook/pointDeduction.hook.test.ts create mode 100644 Club12-WebClient/src/modules/pointDeduction/hook/pointDeduction.hook.ts create mode 100644 Club12-WebClient/src/modules/pointDeduction/service/pointDeduction.service.ts create mode 100644 Club12-WebClient/src/modules/pointDeduction/type/pointDeduction.d.ts create mode 100644 Club12-WebClient/src/modules/scorer/context/scorer.context.tsx create mode 100644 Club12-WebClient/src/modules/scorer/hook/scorer.hook.ts create mode 100644 Club12-WebClient/src/modules/scorer/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/scorer/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/scorer/service/scorer.service.ts create mode 100644 Club12-WebClient/src/modules/scorer/type/scorer.d.ts create mode 100644 Club12-WebClient/src/modules/scorer/utils/scorerScope.test.ts create mode 100644 Club12-WebClient/src/modules/scorer/utils/scorerScope.ts create mode 100644 Club12-WebClient/src/modules/season/context/season.context.test.tsx create mode 100644 Club12-WebClient/src/modules/season/context/season.context.tsx create mode 100644 Club12-WebClient/src/modules/season/hook/season.hook.ts create mode 100644 Club12-WebClient/src/modules/season/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/season/service/season.service.ts create mode 100644 Club12-WebClient/src/modules/season/type/season.d.ts create mode 100644 Club12-WebClient/src/modules/stage/context/stage.context.tsx create mode 100644 Club12-WebClient/src/modules/stage/hook/stage.hook.ts create mode 100644 Club12-WebClient/src/modules/stage/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/stage/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/stage/service/stage.service.ts create mode 100644 Club12-WebClient/src/modules/stage/type/stage.ts create mode 100644 Club12-WebClient/src/modules/stage/utils/stageLabel.ts create mode 100644 Club12-WebClient/src/modules/team/context/team.context.tsx create mode 100644 Club12-WebClient/src/modules/team/hook/team.hook.ts create mode 100644 Club12-WebClient/src/modules/team/hook/useTeamProfile.ts create mode 100644 Club12-WebClient/src/modules/team/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/team/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/team/service/team.service.ts create mode 100644 Club12-WebClient/src/modules/team/type/team.d.ts create mode 100644 Club12-WebClient/src/modules/team/type/teamProfile.d.ts create mode 100644 Club12-WebClient/src/modules/team/utils/teamProfile.test.ts create mode 100644 Club12-WebClient/src/modules/team/utils/teamProfile.ts create mode 100644 Club12-WebClient/src/modules/teamStaff/hook/teamStaff.hook.test.ts create mode 100644 Club12-WebClient/src/modules/teamStaff/hook/teamStaff.hook.ts create mode 100644 Club12-WebClient/src/modules/teamStaff/service/teamStaff.service.ts create mode 100644 Club12-WebClient/src/modules/teamStaff/type/teamStaff.d.ts create mode 100644 Club12-WebClient/src/modules/teamStaff/utils/teamStaffDisplay.ts create mode 100644 Club12-WebClient/src/modules/tournament/context/tournament.context.test.tsx create mode 100644 Club12-WebClient/src/modules/tournament/context/tournament.context.tsx create mode 100644 Club12-WebClient/src/modules/tournament/hook/tournament.hook.ts create mode 100644 Club12-WebClient/src/modules/tournament/service/tournament.service.ts create mode 100644 Club12-WebClient/src/modules/tournament/type/createFullTournament.d.ts create mode 100644 Club12-WebClient/src/modules/tournament/type/tournament.d.ts create mode 100644 Club12-WebClient/src/modules/tournament/utils/completabilityMessages.test.ts create mode 100644 Club12-WebClient/src/modules/tournament/utils/completabilityMessages.ts create mode 100644 Club12-WebClient/src/modules/tournament/utils/tournamentDisplay.ts create mode 100644 Club12-WebClient/src/modules/tournament/utils/tournamentStatusTransitions.test.ts create mode 100644 Club12-WebClient/src/modules/tournament/utils/tournamentStatusTransitions.ts create mode 100644 Club12-WebClient/src/modules/user/context/user.context.tsx create mode 100644 Club12-WebClient/src/modules/user/hook/user.hook.ts create mode 100644 Club12-WebClient/src/modules/user/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/user/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/user/service/user.service.ts create mode 100644 Club12-WebClient/src/modules/user/type/user.d.ts create mode 100644 Club12-WebClient/src/modules/venue/context/venue.context.test.tsx create mode 100644 Club12-WebClient/src/modules/venue/context/venue.context.tsx create mode 100644 Club12-WebClient/src/modules/venue/hook/venue.hook.ts create mode 100644 Club12-WebClient/src/modules/venue/queryKeys.test.ts create mode 100644 Club12-WebClient/src/modules/venue/queryKeys.ts create mode 100644 Club12-WebClient/src/modules/venue/service/venue.service.ts create mode 100644 Club12-WebClient/src/modules/venue/type/venue.d.ts create mode 100644 Club12-WebClient/src/mui-data-grid.d.ts create mode 100644 Club12-WebClient/src/test/setup.ts create mode 100644 Club12-WebClient/src/test/smoke.test.tsx create mode 100644 Club12-WebClient/src/theme.color-tokens.test.ts create mode 100644 Club12-WebClient/src/theme.ts create mode 100644 Club12-WebClient/src/views/auth/PasswordPolicyChecklist.tsx create mode 100644 Club12-WebClient/src/views/auth/activateAccount.tsx create mode 100644 Club12-WebClient/src/views/auth/forgotPassword.tsx create mode 100644 Club12-WebClient/src/views/auth/login.tsx create mode 100644 Club12-WebClient/src/views/auth/passwordReset.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogListPage.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostDetailPage.test.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostDetailPage.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostEditPage.test.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostEditPage.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostImageField.test.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostImageField.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostPreviewDialog.test.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostPreviewDialog.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostsPage.test.tsx create mode 100644 Club12-WebClient/src/views/blogPost/BlogPostsPage.tsx create mode 100644 Club12-WebClient/src/views/blogPost/addBlogPostForm.test.tsx create mode 100644 Club12-WebClient/src/views/blogPost/addBlogPostForm.tsx create mode 100644 Club12-WebClient/src/views/blogPost/showPosts.test.tsx create mode 100644 Club12-WebClient/src/views/blogPost/showPosts.tsx create mode 100644 Club12-WebClient/src/views/champion/Podium.test.tsx create mode 100644 Club12-WebClient/src/views/champion/Podium.tsx create mode 100644 Club12-WebClient/src/views/club/ClubHistoryPage.test.tsx create mode 100644 Club12-WebClient/src/views/club/ClubHistoryPage.tsx create mode 100644 Club12-WebClient/src/views/club/ClubsPage.test.tsx create mode 100644 Club12-WebClient/src/views/club/ClubsPage.tsx create mode 100644 Club12-WebClient/src/views/core/MUI/icons/icons.tsx create mode 100644 Club12-WebClient/src/views/core/components/BasketballCourtPattern.tsx create mode 100644 Club12-WebClient/src/views/core/components/BlockingOverlay.tsx create mode 100644 Club12-WebClient/src/views/core/components/CategoryChip.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/CategoryChip.tsx create mode 100644 Club12-WebClient/src/views/core/components/ComposeProviders.tsx create mode 100644 Club12-WebClient/src/views/core/components/DivisionStagePicker.tsx create mode 100644 Club12-WebClient/src/views/core/components/ErrorPageActions.tsx create mode 100644 Club12-WebClient/src/views/core/components/ErrorPageLayout.tsx create mode 100644 Club12-WebClient/src/views/core/components/ExportCsvButton.tsx create mode 100644 Club12-WebClient/src/views/core/components/FieldInfoTooltip.tsx create mode 100644 Club12-WebClient/src/views/core/components/FilterBar.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/FilterBar.tsx create mode 100644 Club12-WebClient/src/views/core/components/FormButtons.tsx create mode 100644 Club12-WebClient/src/views/core/components/GlobalLoadingOverlay.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/GlobalLoadingOverlay.tsx create mode 100644 Club12-WebClient/src/views/core/components/JerseySvg.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/JerseySvg.tsx create mode 100644 Club12-WebClient/src/views/core/components/LeafletMap.tsx create mode 100644 Club12-WebClient/src/views/core/components/LoadErrorState.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/LoadErrorState.tsx create mode 100644 Club12-WebClient/src/views/core/components/LoadingIndicator.tsx create mode 100644 Club12-WebClient/src/views/core/components/NewEntityButton.tsx create mode 100644 Club12-WebClient/src/views/core/components/PageShell.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/PageShell.tsx create mode 100644 Club12-WebClient/src/views/core/components/PublicLayout.tsx create mode 100644 Club12-WebClient/src/views/core/components/QueryProvider.tsx create mode 100644 Club12-WebClient/src/views/core/components/ScrollToTop.tsx create mode 100644 Club12-WebClient/src/views/core/components/SecondaryTabs.tsx create mode 100644 Club12-WebClient/src/views/core/components/SectionHeading.tsx create mode 100644 Club12-WebClient/src/views/core/components/SidebarLayout.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/SidebarLayout.tsx create mode 100644 Club12-WebClient/src/views/core/components/StatTile.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/StatTile.tsx create mode 100644 Club12-WebClient/src/views/core/components/TableRowActions.tsx create mode 100644 Club12-WebClient/src/views/core/components/TableScrollBox.tsx create mode 100644 Club12-WebClient/src/views/core/components/TeamBackdrop.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/TeamBackdrop.tsx create mode 100644 Club12-WebClient/src/views/core/components/TeamHero.test.tsx create mode 100644 Club12-WebClient/src/views/core/components/TeamHero.tsx create mode 100644 Club12-WebClient/src/views/core/components/TeamLogo.tsx create mode 100644 Club12-WebClient/src/views/core/components/ThemedProvider.tsx create mode 100644 Club12-WebClient/src/views/core/components/buildActionsColumn.tsx create mode 100644 Club12-WebClient/src/views/core/components/skeletons/CardGridSkeleton.tsx create mode 100644 Club12-WebClient/src/views/core/components/skeletons/DetailSkeleton.tsx create mode 100644 Club12-WebClient/src/views/core/components/skeletons/ListSkeleton.tsx create mode 100644 Club12-WebClient/src/views/core/components/skeletons/TableSkeleton.tsx create mode 100644 Club12-WebClient/src/views/core/components/skeletons/index.ts create mode 100644 Club12-WebClient/src/views/core/components/skeletons/skeletons.test.tsx create mode 100644 Club12-WebClient/src/views/core/constants/const.ts create mode 100644 Club12-WebClient/src/views/core/errors/NotFound.tsx create mode 100644 Club12-WebClient/src/views/core/errors/error-boundary.test.tsx create mode 100644 Club12-WebClient/src/views/core/errors/error-boundary.tsx create mode 100644 Club12-WebClient/src/views/core/errors/forbidden.tsx create mode 100644 Club12-WebClient/src/views/core/errors/invalidToken.tsx create mode 100644 Club12-WebClient/src/views/core/privateRoute.tsx create mode 100644 Club12-WebClient/src/views/core/routes-const.ts create mode 100644 Club12-WebClient/src/views/division/DivisionFixture.test.tsx create mode 100644 Club12-WebClient/src/views/division/DivisionFixture.tsx create mode 100644 Club12-WebClient/src/views/division/DivisionFormatSection.test.tsx create mode 100644 Club12-WebClient/src/views/division/DivisionFormatSection.tsx create mode 100644 Club12-WebClient/src/views/division/DivisionScorersTable.test.tsx create mode 100644 Club12-WebClient/src/views/division/DivisionScorersTable.tsx create mode 100644 Club12-WebClient/src/views/division/PointDeductionManager.tsx create mode 100644 Club12-WebClient/src/views/division/PrintableResultsSheet.tsx create mode 100644 Club12-WebClient/src/views/division/divisionCreatePage.test.tsx create mode 100644 Club12-WebClient/src/views/division/divisionCreatePage.tsx create mode 100644 Club12-WebClient/src/views/division/divisionEditPage.test.tsx create mode 100644 Club12-WebClient/src/views/division/divisionEditPage.tsx create mode 100644 Club12-WebClient/src/views/division/divisionPage.test.tsx create mode 100644 Club12-WebClient/src/views/division/divisionPage.tsx create mode 100644 Club12-WebClient/src/views/division/divisionStandings.test.tsx create mode 100644 Club12-WebClient/src/views/division/divisionStandings.tsx create mode 100644 Club12-WebClient/src/views/division/divisionsPage.test.tsx create mode 100644 Club12-WebClient/src/views/division/divisionsPage.tsx create mode 100644 Club12-WebClient/src/views/home/Footer/Footer.tsx create mode 100644 Club12-WebClient/src/views/home/NavMenu/desktop.tsx create mode 100644 Club12-WebClient/src/views/home/NavMenu/mobile.tsx create mode 100644 Club12-WebClient/src/views/home/NavMenu/navMenu.tsx create mode 100644 Club12-WebClient/src/views/home/champions/PublicChampionsPage.test.tsx create mode 100644 Club12-WebClient/src/views/home/champions/PublicChampionsPage.tsx create mode 100644 Club12-WebClient/src/views/home/home.test.tsx create mode 100644 Club12-WebClient/src/views/home/home.tsx create mode 100644 Club12-WebClient/src/views/home/howWeAre/howWeAre.tsx create mode 100644 Club12-WebClient/src/views/home/information/medicalRecord.tsx create mode 100644 Club12-WebClient/src/views/home/information/regulation.tsx create mode 100644 Club12-WebClient/src/views/home/information/regulationContent.ts create mode 100644 Club12-WebClient/src/views/home/matches/MatchFixtureList.test.tsx create mode 100644 Club12-WebClient/src/views/home/matches/MatchFixtureList.tsx create mode 100644 Club12-WebClient/src/views/home/matches/MatchRow.test.tsx create mode 100644 Club12-WebClient/src/views/home/matches/MatchRow.tsx create mode 100644 Club12-WebClient/src/views/home/matches/PublicMatchPage.test.tsx create mode 100644 Club12-WebClient/src/views/home/matches/PublicMatchPage.tsx create mode 100644 Club12-WebClient/src/views/home/matches/SeriesCard.test.tsx create mode 100644 Club12-WebClient/src/views/home/matches/SeriesCard.tsx create mode 100644 Club12-WebClient/src/views/home/sanctions/PublicSanctionsPage.tsx create mode 100644 Club12-WebClient/src/views/home/seasons/PublicSeasonPage.test.tsx create mode 100644 Club12-WebClient/src/views/home/seasons/PublicSeasonPage.tsx create mode 100644 Club12-WebClient/src/views/home/seasons/PublicSeasonsPage.test.tsx create mode 100644 Club12-WebClient/src/views/home/seasons/PublicSeasonsPage.tsx create mode 100644 Club12-WebClient/src/views/home/teams/PublicTeamPage.test.tsx create mode 100644 Club12-WebClient/src/views/home/teams/PublicTeamPage.tsx create mode 100644 Club12-WebClient/src/views/home/teams/TeamStaffSection.test.tsx create mode 100644 Club12-WebClient/src/views/home/teams/TeamStaffSection.tsx create mode 100644 Club12-WebClient/src/views/home/tournaments/PublicDivisionPanel.test.tsx create mode 100644 Club12-WebClient/src/views/home/tournaments/PublicDivisionPanel.tsx create mode 100644 Club12-WebClient/src/views/home/tournaments/PublicTeamGrid.tsx create mode 100644 Club12-WebClient/src/views/home/tournaments/PublicTournamentPage.test.tsx create mode 100644 Club12-WebClient/src/views/home/tournaments/PublicTournamentPage.tsx create mode 100644 Club12-WebClient/src/views/match/MatchStatisticsTab.test.tsx create mode 100644 Club12-WebClient/src/views/match/MatchStatisticsTab.tsx create mode 100644 Club12-WebClient/src/views/match/MatchStatusChip.test.tsx create mode 100644 Club12-WebClient/src/views/match/MatchStatusChip.tsx create mode 100644 Club12-WebClient/src/views/match/StageMatchesByRound.test.tsx create mode 100644 Club12-WebClient/src/views/match/StageMatchesByRound.tsx create mode 100644 Club12-WebClient/src/views/match/matchPage.tsx create mode 100644 Club12-WebClient/src/views/medicalRecord/HabilitacionBadge.test.tsx create mode 100644 Club12-WebClient/src/views/medicalRecord/HabilitacionBadge.tsx create mode 100644 Club12-WebClient/src/views/medicalRecord/PlayerMedicalRecordDialog.test.tsx create mode 100644 Club12-WebClient/src/views/medicalRecord/PlayerMedicalRecordDialog.tsx create mode 100644 Club12-WebClient/src/views/panel/AuditLogsPage.test.tsx create mode 100644 Club12-WebClient/src/views/panel/AuditLogsPage.tsx create mode 100644 Club12-WebClient/src/views/panel/ChangePasswordPage.tsx create mode 100644 Club12-WebClient/src/views/panel/DataAdministrationPage.test.tsx create mode 100644 Club12-WebClient/src/views/panel/DataAdministrationPage.tsx create mode 100644 Club12-WebClient/src/views/panel/StatisticsPage.test.tsx create mode 100644 Club12-WebClient/src/views/panel/StatisticsPage.tsx create mode 100644 Club12-WebClient/src/views/panel/UsersPage.tsx create mode 100644 Club12-WebClient/src/views/panel/components/BackupsTable.test.tsx create mode 100644 Club12-WebClient/src/views/panel/components/BackupsTable.tsx create mode 100644 Club12-WebClient/src/views/panel/statisticsFilters.test.ts create mode 100644 Club12-WebClient/src/views/panel/statisticsFilters.ts create mode 100644 Club12-WebClient/src/views/player/PlayerFormDialog.tsx create mode 100644 Club12-WebClient/src/views/player/PlayerFormFields.tsx create mode 100644 Club12-WebClient/src/views/player/PlayerPage.test.tsx create mode 100644 Club12-WebClient/src/views/player/PlayerPage.tsx create mode 100644 Club12-WebClient/src/views/player/PlayersPage.test.tsx create mode 100644 Club12-WebClient/src/views/player/PlayersPage.tsx create mode 100644 Club12-WebClient/src/views/player/players.types.ts create mode 100644 Club12-WebClient/src/views/playerSanction/PlayerSanctionPage.test.tsx create mode 100644 Club12-WebClient/src/views/playerSanction/PlayerSanctionPage.tsx create mode 100644 Club12-WebClient/src/views/playerSanction/PlayerSanctionsPage.test.tsx create mode 100644 Club12-WebClient/src/views/playerSanction/PlayerSanctionsPage.tsx create mode 100644 Club12-WebClient/src/views/playerSanction/SanctionSubjectFields.test.tsx create mode 100644 Club12-WebClient/src/views/playerSanction/SanctionSubjectFields.tsx create mode 100644 Club12-WebClient/src/views/playerSanction/playerSanctionCreatePage.tsx create mode 100644 Club12-WebClient/src/views/playerSanction/playerSanctionDeletePage.tsx create mode 100644 Club12-WebClient/src/views/playerSanction/playerSanctionEditPage.test.tsx create mode 100644 Club12-WebClient/src/views/playerSanction/playerSanctionEditPage.tsx create mode 100644 Club12-WebClient/src/views/playerStatistic/PlayerHistory.test.tsx create mode 100644 Club12-WebClient/src/views/playerStatistic/PlayerHistory.tsx create mode 100644 Club12-WebClient/src/views/playerStatistic/PlayerStatisticCard.test.tsx create mode 100644 Club12-WebClient/src/views/playerStatistic/PlayerStatisticCard.tsx create mode 100644 Club12-WebClient/src/views/playerStatistic/playerStatisticCreatePage.tsx create mode 100644 Club12-WebClient/src/views/playoff/BracketMatchLibraryAdapter.test.tsx create mode 100644 Club12-WebClient/src/views/playoff/BracketMatchLibraryAdapter.tsx create mode 100644 Club12-WebClient/src/views/playoff/BracketMatchNode.test.tsx create mode 100644 Club12-WebClient/src/views/playoff/BracketMatchNode.tsx create mode 100644 Club12-WebClient/src/views/playoff/PlayoffBracket.test.tsx create mode 100644 Club12-WebClient/src/views/playoff/PlayoffBracket.tsx create mode 100644 Club12-WebClient/src/views/playoff/PlayoffCups.test.tsx create mode 100644 Club12-WebClient/src/views/playoff/PlayoffCups.tsx create mode 100644 Club12-WebClient/src/views/playoff/PlayoffDrawDialog.test.tsx create mode 100644 Club12-WebClient/src/views/playoff/PlayoffDrawDialog.tsx create mode 100644 Club12-WebClient/src/views/playoff/SeriesInProgressPanel.test.tsx create mode 100644 Club12-WebClient/src/views/playoff/SeriesInProgressPanel.tsx create mode 100644 Club12-WebClient/src/views/playoff/playoffBracketTheme.ts create mode 100644 Club12-WebClient/src/views/season/AdminSeasonDetailPage.test.tsx create mode 100644 Club12-WebClient/src/views/season/AdminSeasonDetailPage.tsx create mode 100644 Club12-WebClient/src/views/season/SeasonFormDialog.tsx create mode 100644 Club12-WebClient/src/views/season/SeasonsPage.test.tsx create mode 100644 Club12-WebClient/src/views/season/SeasonsPage.tsx create mode 100644 Club12-WebClient/src/views/team/RosterCsvImportDialog.test.tsx create mode 100644 Club12-WebClient/src/views/team/RosterCsvImportDialog.tsx create mode 100644 Club12-WebClient/src/views/team/TeamFormDialog.test.tsx create mode 100644 Club12-WebClient/src/views/team/TeamFormDialog.tsx create mode 100644 Club12-WebClient/src/views/team/TeamFormFields.tsx create mode 100644 Club12-WebClient/src/views/team/TeamPage.test.tsx create mode 100644 Club12-WebClient/src/views/team/TeamPage.tsx create mode 100644 Club12-WebClient/src/views/team/TeamStaffManager.test.tsx create mode 100644 Club12-WebClient/src/views/team/TeamStaffManager.tsx create mode 100644 Club12-WebClient/src/views/team/TeamsFilterBar.test.tsx create mode 100644 Club12-WebClient/src/views/team/TeamsFilterBar.tsx create mode 100644 Club12-WebClient/src/views/team/TeamsPage.test.tsx create mode 100644 Club12-WebClient/src/views/team/TeamsPage.tsx create mode 100644 Club12-WebClient/src/views/team/TeamsTable.test.tsx create mode 100644 Club12-WebClient/src/views/team/TeamsTable.tsx create mode 100644 Club12-WebClient/src/views/team/teams.types.ts create mode 100644 Club12-WebClient/src/views/tournament/EnrollTeamDialog.tsx create mode 100644 Club12-WebClient/src/views/tournament/TournamentDivisionAssignment.test.tsx create mode 100644 Club12-WebClient/src/views/tournament/TournamentDivisionAssignment.tsx create mode 100644 Club12-WebClient/src/views/tournament/TournamentEditPage.tsx create mode 100644 Club12-WebClient/src/views/tournament/TournamentEnrolledTeams.test.tsx create mode 100644 Club12-WebClient/src/views/tournament/TournamentEnrolledTeams.tsx create mode 100644 Club12-WebClient/src/views/tournament/TournamentPage.test.tsx create mode 100644 Club12-WebClient/src/views/tournament/TournamentPage.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/TournamentWizardPage.test.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/TournamentWizardPage.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/TreeNodeList.test.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/TreeNodeList.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/cloneWizard.test.ts create mode 100644 Club12-WebClient/src/views/tournament/wizard/cloneWizard.ts create mode 100644 Club12-WebClient/src/views/tournament/wizard/steps/CopaCruzadaStep.test.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/steps/CopaCruzadaStep.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/steps/CupsEditor.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/steps/DivisionesStep.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/steps/RevisionStep.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/steps/TorneoStep.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/steps/ZoneEditor.tsx create mode 100644 Club12-WebClient/src/views/tournament/wizard/submitWizard.test.ts create mode 100644 Club12-WebClient/src/views/tournament/wizard/submitWizard.ts create mode 100644 Club12-WebClient/src/views/tournament/wizard/types.ts create mode 100644 Club12-WebClient/src/views/tournament/wizard/wizardLogic.test.ts create mode 100644 Club12-WebClient/src/views/tournament/wizard/wizardLogic.ts create mode 100644 Club12-WebClient/src/views/user/createUser.test.tsx create mode 100644 Club12-WebClient/src/views/user/createUser.tsx create mode 100644 Club12-WebClient/src/views/user/editUser.test.tsx create mode 100644 Club12-WebClient/src/views/user/editUser.tsx create mode 100644 Club12-WebClient/src/views/user/inviteUser.tsx create mode 100644 Club12-WebClient/src/views/user/updatePassword.tsx create mode 100644 Club12-WebClient/src/views/user/userDetails.tsx create mode 100644 Club12-WebClient/src/views/venue/VenueFormDialog.tsx create mode 100644 Club12-WebClient/src/views/venue/VenueFormFields.test.tsx create mode 100644 Club12-WebClient/src/views/venue/VenueFormFields.tsx create mode 100644 Club12-WebClient/src/views/venue/VenuePage.test.tsx create mode 100644 Club12-WebClient/src/views/venue/VenuesPage.test.tsx create mode 100644 Club12-WebClient/src/views/venue/VenuesPage.tsx create mode 100644 Club12-WebClient/src/views/venue/venuePage.tsx create mode 100644 Club12-WebClient/src/views/venue/venues.types.ts create mode 100644 Club12-WebClient/src/vite-env.d.ts diff --git a/Club12-WebClient/src/App.css b/Club12-WebClient/src/App.css new file mode 100644 index 0000000..d08b22f --- /dev/null +++ b/Club12-WebClient/src/App.css @@ -0,0 +1,3 @@ +body{ + margin: 0 !important; +} \ No newline at end of file diff --git a/Club12-WebClient/src/App.test.tsx b/Club12-WebClient/src/App.test.tsx new file mode 100644 index 0000000..7ec0d81 --- /dev/null +++ b/Club12-WebClient/src/App.test.tsx @@ -0,0 +1,79 @@ +import { render, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import App from '@/App'; +import { UserRolesType } from '@/modules/core/enum/user/userRolesType'; + +// Mutable so a test can flip the session to authenticated. Hoisted because the +// vi.mock factory below runs before module imports. +const authState = vi.hoisted(() => ({ + isAuthenticated: false, + role: 'Guest' as string, +})); + +vi.mock('@/modules/auth/hook/auth.hook', () => ({ + useAuth: () => ({ + isAuthenticated: authState.isAuthenticated, + role: authState.role, + signIn: vi.fn(), + logOut: vi.fn(), + user: null, + }), +})); + +afterEach(() => { + authState.isAuthenticated = false; + authState.role = 'Guest'; +}); + +const renderAt = (path: string) => + render( + + + + ); + +describe('App public layout chrome', () => { + it('HU-02: renders /login without header or footer', async () => { + renderAt('/login'); + + // Route-level pages are React.lazy-loaded (see App.tsx), so the chunk + // resolves asynchronously behind a Suspense fallback — a synchronous + // getByText would race that and fail before the real content mounts. + expect(await screen.findByText('Administrador')).toBeInTheDocument(); + expect(document.querySelector('header')).toBeNull(); + expect(document.querySelector('footer')).toBeNull(); + }); + + it('HU-04: renders the 404 page without header or footer', async () => { + renderAt('/una-ruta-que-no-existe'); + + expect( + await screen.findByText(/no existe o fue movida/i) + ).toBeInTheDocument(); + expect(document.querySelector('header')).toBeNull(); + expect(document.querySelector('footer')).toBeNull(); + }); + + it('keeps header and footer on a normal public route', async () => { + renderAt('/quienes-somos'); + + expect(await screen.findByRole('contentinfo')).toBeInTheDocument(); + expect(document.querySelector('header')).not.toBeNull(); + }); + + it('lets an authenticated admin open a public page instead of 404', async () => { + // Regression: public slug routes (tournament/blog/team/match) used to be + // omitted entirely for authenticated users, so any public URL 404'd from + // the panel catch-all without ever hitting the API. They must resolve for + // logged-in users too, under the public layout (not the admin sidebar). + authState.isAuthenticated = true; + authState.role = UserRolesType.Admin; + + renderAt('/quienes-somos'); + + expect(await screen.findByRole('contentinfo')).toBeInTheDocument(); + expect(screen.queryByText(/no existe o fue movida/i)).toBeNull(); + expect(document.querySelector('header')).not.toBeNull(); + }); +}); diff --git a/Club12-WebClient/src/App.tsx b/Club12-WebClient/src/App.tsx new file mode 100644 index 0000000..94fcb5d --- /dev/null +++ b/Club12-WebClient/src/App.tsx @@ -0,0 +1,362 @@ +import '@fontsource/roboto/300.css'; +import '@fontsource/roboto/400.css'; +import '@fontsource/roboto/500.css'; +import '@fontsource/roboto/700.css'; +import '@fontsource/oswald/500.css'; +import '@fontsource/oswald/600.css'; +import '@fontsource/oswald/700.css'; +import { lazy, ReactElement, Suspense } from 'react'; +import { Navigate, Outlet, Route, Routes, useLocation } from 'react-router-dom'; +import routes from './modules/core/constants/routes'; +import { APP_ROUTES } from './modules/core/constants/appRoutes'; +import { useAuth } from './modules/auth/hook/auth.hook'; +import SidebarLayout from './views/core/components/SidebarLayout'; +import PublicLayout from './views/core/components/PublicLayout'; +import { UserRolesType } from './modules/core/enum/user/userRolesType'; +import InvalidToken from './views/core/errors/invalidToken'; +import Forbidden from './views/core/errors/forbidden'; +import NotFound from './views/core/errors/NotFound'; +import PrivateRoute from './views/core/privateRoute'; +import ScrollToTop from './views/core/components/ScrollToTop'; +import GlobalLoadingOverlay from './views/core/components/GlobalLoadingOverlay'; +import BlockingOverlay from './views/core/components/BlockingOverlay'; + +// Every route-level page is loaded on demand instead of shipped in the one +// main bundle every visitor downloads on first paint — the whole admin +// panel (Jugadores, Sanciones, the tournament wizard, …) was landing in a +// public visitor's browser just to render the home page. `NotFound`, +// `Forbidden` and `InvalidToken` stay eager: `App()` can return them +// directly from an early check below, outside the boundary the +// tree sits in, and they're tiny enough that splitting them buys +// nothing. +const Home = lazy(() => import('./views/home/home')); +const PublicTeamPage = lazy(() => import('./views/home/teams/PublicTeamPage')); +const PublicSanctionsPage = lazy(() => import('./views/home/sanctions/PublicSanctionsPage')); +const PublicChampionsPage = lazy(() => import('./views/home/champions/PublicChampionsPage')); +const PublicMatchPage = lazy(() => import('./views/home/matches/PublicMatchPage')); +const PublicTournamentPage = lazy(() => import('./views/home/tournaments/PublicTournamentPage')); +const PublicSeasonsPage = lazy(() => import('./views/home/seasons/PublicSeasonsPage')); +const PublicSeasonPage = lazy(() => import('./views/home/seasons/PublicSeasonPage')); +const BlogPostDetailPage = lazy(() => import('./views/blogPost/BlogPostDetailPage')); +const BlogListPage = lazy(() => import('./views/blogPost/BlogListPage')); +const AddBlogPostForm = lazy(() => import('./views/blogPost/addBlogPostForm')); +const BlogPostsPage = lazy(() => import('./views/blogPost/BlogPostsPage')); +const BlogPostEditPage = lazy(() => import('./views/blogPost/BlogPostEditPage')); +const Login = lazy(() => import('./views/auth/login')); +const HowWeAre = lazy(() => import('./views/home/howWeAre/howWeAre')); +const MedicalRecord = lazy(() => import('./views/home/information/medicalRecord')); +const Regulation = lazy(() => import('./views/home/information/regulation')); +const PlayersPage = lazy(() => import('./views/player/PlayersPage')); +const PlayerPage = lazy(() => import('./views/player/PlayerPage')); +const TeamPage = lazy(() => import('./views/team/TeamPage')); +const TournamentPage = lazy(() => import('./views/tournament/TournamentPage')); +const TournamentEditPage = lazy(() => import('./views/tournament/TournamentEditPage')); +const TournamentWizardPage = lazy(() => import('./views/tournament/wizard/TournamentWizardPage')); +const DivisionPage = lazy(() => import('./views/division/divisionPage')); +const DivisionCreatePage = lazy(() => import('./views/division/divisionCreatePage')); +const DivisionEditPage = lazy(() => import('./views/division/divisionEditPage')); +const MatchPage = lazy(() => import('./views/match/matchPage')); +const UsersPage = lazy(() => import('./views/panel/UsersPage')); +const UserDetails = lazy(() => import('./views/user/userDetails')); +const CreateUser = lazy(() => import('./views/user/createUser')); +const InviteUser = lazy(() => import('./views/user/inviteUser')); +const EditUser = lazy(() => import('./views/user/editUser')); +const ChangePasswordPage = lazy(() => import('./views/panel/ChangePasswordPage')); +const StatisticsPage = lazy(() => import('./views/panel/StatisticsPage')); +const AuditLogsPage = lazy(() => import('./views/panel/AuditLogsPage')); +const DataAdministrationPage = lazy(() => import('./views/panel/DataAdministrationPage')); +const PasswordReset = lazy(() => import('./views/auth/passwordReset')); +const ForgotPassword = lazy(() => import('./views/auth/forgotPassword')); +const ActivateAccount = lazy(() => import('./views/auth/activateAccount')); +const ClubsPage = lazy(() => import('./views/club/ClubsPage')); +const ClubHistoryPage = lazy(() => import('./views/club/ClubHistoryPage')); +const PlayerSanctionsPage = lazy(() => import('./views/playerSanction/PlayerSanctionsPage')); +const PlayerSanctionPage = lazy(() => import('./views/playerSanction/PlayerSanctionPage')); +const PlayerSanctionEditPage = lazy(() => import('./views/playerSanction/playerSanctionEditPage')); +const VenuesPage = lazy(() => import('./views/venue/VenuesPage')); +const VenuePage = lazy(() => import('./views/venue/venuePage')); +const SeasonsPage = lazy(() => import('./views/season/SeasonsPage')); +const AdminSeasonDetailPage = lazy(() => import('./views/season/AdminSeasonDetailPage')); + +const FIRST_TAB_BY_ROLE: Partial> = { + [UserRolesType.Owner]: APP_ROUTES.panelSeasons, + [UserRolesType.Admin]: APP_ROUTES.panelSeasons, +}; + +interface AdminRouteConfig { + path: string; + element: ReactElement; + allowedRoles?: UserRolesType[]; +} + +const ADMIN_ROUTES: AdminRouteConfig[] = [ + { path: APP_ROUTES.passwordReset, element: }, + { + path: APP_ROUTES.panelPlayers, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelPlayer.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelTeamDetail.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelTournamentDetail.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelTournamentEdit.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelTeams, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelClub.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelSanctions, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelSanction.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelSanctionEdit.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelVenues, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelVenue.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelSeasons, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelSeason.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelTournamentWizard, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelDivisionCreate, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelDivisionEdit.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelDivision.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelMatch.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelBlog, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelBlogCreate, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelBlogEdit.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelUsers, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelUserCreate, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelUserInvite, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelUserEdit.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelUser.pattern, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelSettings, + element: , + }, + { + path: APP_ROUTES.panelChangePassword, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelEditProfile, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelStatistics, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelAuditLogs, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { + path: APP_ROUTES.panelDataAdministration, + allowedRoles: [UserRolesType.Admin, UserRolesType.Owner], + element: , + }, + { path: '*', element: }, +]; + +interface PublicRouteConfig { + path: string; + element: ReactElement; +} + +const PUBLIC_ROUTES: PublicRouteConfig[] = [ + { path: APP_ROUTES.passwordReset, element: }, + { path: APP_ROUTES.home, element: }, + { path: APP_ROUTES.quienesSomos, element: }, + { path: APP_ROUTES.fichaMedica, element: }, + { path: APP_ROUTES.reglamento, element: }, + { path: APP_ROUTES.publicTeam.pattern, element: }, + { path: APP_ROUTES.publicSanctions, element: }, + { path: APP_ROUTES.publicChampions, element: }, + { path: APP_ROUTES.publicMatch.pattern, element: }, + { path: APP_ROUTES.publicSeasons, element: }, + { path: APP_ROUTES.publicSeason.pattern, element: }, + // No `/torneos` flat listing route: it was never linked from any nav + // (dead/unreachable, HU orphan-route audit) — every tournament is reached + // via Temporadas -> season -> tournament instead. + { + path: APP_ROUTES.publicTournament.pattern, + element: , + }, + { path: APP_ROUTES.publicBlog, element: }, + { path: APP_ROUTES.blogPost.pattern, element: }, +]; + +function App() { + const { isAuthenticated, role } = useAuth(); + const location = useLocation(); + + if (location.pathname === APP_ROUTES.forbidden) return ; + if (location.pathname === routes.tokenInvalido) return ; + + const defaultTab = FIRST_TAB_BY_ROLE[role] ?? APP_ROUTES.panelUsers; + + // Public pages render for EVERYONE — authenticated or not — so shareable + // slug links (public tournament HU-14, blog post HU-13, team/match, HU-15) + // keep working even while an admin is logged in. Previously the whole public + // route tree was omitted for authenticated users, so any public slug URL fell + // through to the panel catch-all and 404'd without ever hitting the API. + // + // Login (HU-02) and the 404/NotFound catch-all (HU-04) render without the + // public header/footer, so they sit outside the PublicLayout chrome. The + // admin panel is only mounted when authenticated, under one persistent + // SidebarLayout (via ) so the sidebar survives panel navigation. + return ( + <> + + + }> + + }> + {PUBLIC_ROUTES.map(({ path, element }) => ( + + ))} + + } /> + } /> + } /> + + {isAuthenticated && ( + + + + } + > + {ADMIN_ROUTES.filter(({ path }) => path !== '*').map( + ({ path, element, allowedRoles }) => ( + + {element} + + ) : ( + element + ) + } + /> + ) + )} + } + /> + + )} + + } /> + + + + ); +} + +export default App; diff --git a/Club12-WebClient/src/design/categoryColor.test.ts b/Club12-WebClient/src/design/categoryColor.test.ts new file mode 100644 index 0000000..3808430 --- /dev/null +++ b/Club12-WebClient/src/design/categoryColor.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; +import { categoryColor } from './categoryColor'; +import { category } from './tokens'; + +describe('categoryColor', () => { + it('tints the masculine category with the brand orange', () => { + const { fill, ink } = categoryColor(TournamentCategory.Masculine); + + expect(fill).toBe(category.masculine); + // Regression: white text on this orange is only ~2.86:1 (fails WCAG AA's + // 4.5:1) — dark ink is the one that actually reads on it, ~6.3:1. + expect(ink).toBe('#0b0f17'); + }); + + it('tints the feminine category with the brand purple', () => { + const { fill, ink } = categoryColor(TournamentCategory.Feminine); + + expect(fill).toBe('#A32CC4'); + expect(ink).toBe('#f5f5f5'); + }); +}); diff --git a/Club12-WebClient/src/design/categoryColor.ts b/Club12-WebClient/src/design/categoryColor.ts new file mode 100644 index 0000000..892492f --- /dev/null +++ b/Club12-WebClient/src/design/categoryColor.ts @@ -0,0 +1,27 @@ +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; +import { LIGHT_INK_LUMINANCE_THRESHOLD, luminance } from './colorName'; +import { category } from './tokens'; + +/** + * The single source for category branding hues (masculine -> orange, feminine + * -> purple) plus the ink that stays legible on top. Chips and section accents + * read from here so the masculine/feminine visual language is defined once and + * never drifts between surfaces. + */ +export interface CategoryColor { + /** The category's brand fill (#rrggbb). */ + fill: string; + /** A contrasting ink (near-black on light fills, off-white on dark ones). */ + ink: string; +} + +/** Resolves a tournament category into its brand fill plus a legible ink. */ +export const categoryColor = (cat: TournamentCategory): CategoryColor => { + const fill = + cat === TournamentCategory.Feminine ? category.feminine : category.masculine; + // Mirror resolveShirtColor's threshold/ink so contrast stays consistent. + return { + fill, + ink: luminance(fill) > LIGHT_INK_LUMINANCE_THRESHOLD ? '#0b0f17' : '#f5f5f5', + }; +}; diff --git a/Club12-WebClient/src/design/colorName.test.ts b/Club12-WebClient/src/design/colorName.test.ts new file mode 100644 index 0000000..dd0bba4 --- /dev/null +++ b/Club12-WebClient/src/design/colorName.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { isHexColor, luminance, resolveShirtColor } from './colorName'; +import { brand } from './tokens'; + +describe('isHexColor', () => { + it('accepts #rgb and #rrggbb', () => { + expect(isHexColor('#f00')).toBe(true); + expect(isHexColor('#FF0000')).toBe(true); + }); + + it('rejects names, empty, and malformed values', () => { + expect(isHexColor('rojo')).toBe(false); + expect(isHexColor('')).toBe(false); + expect(isHexColor(undefined)).toBe(false); + expect(isHexColor('#12')).toBe(false); + }); +}); + +describe('luminance', () => { + it('is near 0 for black and near 1 for white', () => { + expect(luminance('#000000')).toBeCloseTo(0, 2); + expect(luminance('#ffffff')).toBeCloseTo(1, 2); + }); +}); + +describe('resolveShirtColor', () => { + it('keeps a valid hex and expands the short form', () => { + expect(resolveShirtColor('#FF0000').fill).toBe('#ff0000'); + expect(resolveShirtColor('#fff').fill).toBe('#ffffff'); + }); + + it('picks dark ink on a light fill and light ink on a dark fill', () => { + const white = resolveShirtColor('#ffffff'); + expect(white.isLight).toBe(true); + expect(white.ink).toBe('#0b0f17'); + + const navy = resolveShirtColor('#0f2e6b'); + expect(navy.isLight).toBe(false); + expect(navy.ink).toBe('#f5f5f5'); + }); + + it('falls back to the navy chrome hue for non-hex or empty values', () => { + expect(resolveShirtColor('Rojo').fill).toBe(brand.navyLight); + expect(resolveShirtColor(undefined).fill).toBe(brand.navyLight); + expect(resolveShirtColor('').fill).toBe(brand.navyLight); + }); +}); diff --git a/Club12-WebClient/src/design/colorName.ts b/Club12-WebClient/src/design/colorName.ts new file mode 100644 index 0000000..97a0761 --- /dev/null +++ b/Club12-WebClient/src/design/colorName.ts @@ -0,0 +1,88 @@ +import { brand } from './tokens'; + +/** + * Team shirt colors are chosen with a color picker, so the stored value is a + * `#rrggbb` hex. This module resolves that hex into a usable fill plus the ink + * color that stays legible on top of it. Anything that is not a valid hex + * (e.g. an empty value, or legacy free-text left over from before the picker) + * falls back to the navy chrome hue so a jersey never renders with no fill. + */ + +const HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +/** Expands a 3-digit hex (#abc) to its 6-digit form (#aabbcc). */ +const expandHex = (hex: string): string => { + if (hex.length === 4) { + const [, r, g, b] = hex; + return `#${r}${r}${g}${g}${b}${b}`; + } + return hex; +}; + +/** True when the value is a valid `#rgb` or `#rrggbb` hex string. */ +export const isHexColor = (value?: string | null): boolean => + typeof value === 'string' && HEX_RE.test(value.trim()); + +/** + * Turns a `#rgb`/`#rrggbb` hex into an `rgba()` string at the given alpha, so a + * brand hue can be used as a translucent tint/overlay. Non-hex values fall back + * to the navy chrome hue so a surface never renders with a broken color. + */ +export const hexToRgba = (hex: string, alpha: number): string => { + const full = HEX_RE.test(hex.trim()) + ? expandHex(hex.trim()) + : brand.navyLight; + const r = parseInt(full.slice(1, 3), 16); + const g = parseInt(full.slice(3, 5), 16); + const b = parseInt(full.slice(5, 7), 16); + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + +/** Relative luminance (WCAG) of a #rrggbb color, in the 0..1 range. */ +export const luminance = (hex: string): number => { + const full = expandHex(hex); + const channel = (v: number): number => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }; + const r = channel(parseInt(full.slice(1, 3), 16)); + const g = channel(parseInt(full.slice(3, 5), 16)); + const b = channel(parseInt(full.slice(5, 7), 16)); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +/** + * The fill luminance at which our two ink colors (#0b0f17 dark, #f5f5f5 + * light) give EQUAL contrast against it — below this, dark ink actually + * contrasts better even though the fill isn't "dark" in the everyday sense. + * Solving (fill+0.05)/(inkDark+0.05) = (inkLight+0.05)/(fill+0.05) for our + * actual ink luminances (~0.0047 and ~0.913) gives ≈0.1796. + * + * The previous threshold (0.55) was picked without this math and left a + * wide "medium" band — anything from ~0.18 to 0.55 luminance — defaulting to + * light ink even where dark ink was the better (sometimes WCAG-AA-passing) + * choice. The brand orange (#FF5A1F, luminance ≈0.29) was exactly such a + * case: white text on it is only 2.86:1 (fails the 4.5:1 AA minimum), while + * dark ink on it is ≈6.3:1. + */ +export const LIGHT_INK_LUMINANCE_THRESHOLD = 0.18; + +export interface ResolvedColor { + /** The resolved #rrggbb fill. */ + fill: string; + /** A contrasting ink (near-black on light fills, off-white on dark ones). */ + ink: string; + /** True when the fill is light enough that dark ink reads better on it. */ + isLight: boolean; +} + +/** + * Turns a stored shirt-color value (a hex, or nothing) into a resolved fill + * plus the ink color that stays legible on top of it. + */ +export const resolveShirtColor = (value?: string | null): ResolvedColor => { + const raw = (value ?? '').trim().toLowerCase(); + const fill = HEX_RE.test(raw) ? expandHex(raw) : brand.navyLight; + const isLight = luminance(fill) > LIGHT_INK_LUMINANCE_THRESHOLD; + return { fill, ink: isLight ? '#0b0f17' : '#f5f5f5', isLight }; +}; diff --git a/Club12-WebClient/src/design/jerseyStyles.test.ts b/Club12-WebClient/src/design/jerseyStyles.test.ts new file mode 100644 index 0000000..d3ec20b --- /dev/null +++ b/Club12-WebClient/src/design/jerseyStyles.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_JERSEY_STYLE, + JERSEY_STYLES, + isJerseyStyle, + toJerseyStyle, +} from './jerseyStyles'; + +describe('jerseyStyles', () => { + it('exposes a labelled option per template', () => { + expect(JERSEY_STYLES.map(s => s.value)).toEqual([ + 'solid', + 'stripes', + 'hoops', + 'diagonal', + 'chevron', + 'sash', + 'sides', + 'halves', + 'circles', + 'gradient', + 'vneck', + 'pinstripe', + 'yoke', + 'colorblock', + 'arrow', + 'camo', + 'checkerboard', + 'diamonds', + 'star', + 'triband', + 'shoulder', + 'splitTri', + 'frame', + 'crossband', + 'ring', + ]); + JERSEY_STYLES.forEach(s => expect(s.label.trim().length).toBeGreaterThan(0)); + }); + + it('narrows only known styles', () => { + expect(isJerseyStyle('stripes')).toBe(true); + expect(isJerseyStyle('bogus')).toBe(false); + expect(isJerseyStyle(undefined)).toBe(false); + }); + + it('coerces unknown values to the default style', () => { + expect(toJerseyStyle('sash')).toBe('sash'); + expect(toJerseyStyle('bogus')).toBe(DEFAULT_JERSEY_STYLE); + expect(toJerseyStyle(null)).toBe(DEFAULT_JERSEY_STYLE); + }); +}); diff --git a/Club12-WebClient/src/design/jerseyStyles.ts b/Club12-WebClient/src/design/jerseyStyles.ts new file mode 100644 index 0000000..91e1063 --- /dev/null +++ b/Club12-WebClient/src/design/jerseyStyles.ts @@ -0,0 +1,81 @@ +/** + * Jersey templates — the selectable "kit" patterns a team can pick, in the + * spirit of the Wikipedia team-kit diagrams (a fillable body plus a pattern + * layer). Each template is rendered by {@link JerseySvg} from the team's + * colors: a primary body color, a secondary color used for the pattern/trim, + * and — for the tri-color templates only — an optional third accent color. + * Authoring the shapes ourselves keeps them inline, tintable at runtime, and + * free of any external asset. + */ +export type JerseyStyle = + | 'solid' + | 'stripes' + | 'hoops' + | 'diagonal' + | 'chevron' + | 'sash' + | 'sides' + | 'halves' + | 'circles' + | 'gradient' + | 'vneck' + | 'pinstripe' + | 'yoke' + | 'colorblock' + | 'arrow' + | 'camo' + | 'checkerboard' + | 'diamonds' + | 'star' + | 'triband' + | 'shoulder' + | 'splitTri' + | 'frame' + | 'crossband' + | 'ring'; + +export interface JerseyStyleOption { + value: JerseyStyle; + /** Spanish label shown in the picker. */ + label: string; + /** Whether this template uses a third accent color when one is set. */ + usesTertiary?: boolean; +} + +export const JERSEY_STYLES: JerseyStyleOption[] = [ + { value: 'solid', label: 'Lisa' }, + { value: 'stripes', label: 'Rayas verticales' }, + { value: 'hoops', label: 'Franjas horizontales' }, + { value: 'diagonal', label: 'Rayas diagonales' }, + { value: 'chevron', label: 'Chevrón' }, + { value: 'sash', label: 'Banda diagonal' }, + { value: 'sides', label: 'Laterales' }, + { value: 'halves', label: 'Mitades' }, + { value: 'circles', label: 'Lunares' }, + { value: 'gradient', label: 'Degradé' }, + { value: 'vneck', label: 'Cuello en V' }, + { value: 'pinstripe', label: 'Rayas finas' }, + { value: 'yoke', label: 'Canesú' }, + { value: 'colorblock', label: 'Bloque diagonal' }, + { value: 'arrow', label: 'Flecha' }, + { value: 'camo', label: 'Camuflaje' }, + { value: 'checkerboard', label: 'Cuadros' }, + { value: 'diamonds', label: 'Rombos' }, + { value: 'star', label: 'Estrella' }, + { value: 'triband', label: 'Tribanda', usesTertiary: true }, + { value: 'shoulder', label: 'Hombreras' }, + { value: 'splitTri', label: 'Tres paneles', usesTertiary: true }, + { value: 'frame', label: 'Marco', usesTertiary: true }, + { value: 'crossband', label: 'Cruz diagonal' }, + { value: 'ring', label: 'Aro numeral', usesTertiary: true }, +]; + +export const DEFAULT_JERSEY_STYLE: JerseyStyle = 'solid'; + +/** Narrows an arbitrary stored string to a known {@link JerseyStyle}. */ +export const isJerseyStyle = (value: unknown): value is JerseyStyle => + typeof value === 'string' && JERSEY_STYLES.some(style => style.value === value); + +/** Coerces a stored value into a valid style, defaulting when unrecognized. */ +export const toJerseyStyle = (value: unknown): JerseyStyle => + isJerseyStyle(value) ? value : DEFAULT_JERSEY_STYLE; diff --git a/Club12-WebClient/src/design/tokens.ts b/Club12-WebClient/src/design/tokens.ts new file mode 100644 index 0000000..e4bea5e --- /dev/null +++ b/Club12-WebClient/src/design/tokens.ts @@ -0,0 +1,123 @@ +/** + * Club 12 design tokens — the single source of truth for the brand's visual + * language (dark-first, orange accent, navy "scoreboard" chrome). These values + * were previously scattered as private constants inside `theme.ts`; centralizing + * them here lets components and one-off surfaces read the same hues, radii and + * spacing the MUI theme is built from, instead of hardcoding hex strings. + * + * The MUI theme (`theme.ts`) is assembled from these tokens; prefer reading + * `theme.palette` inside components. Reach for a raw token only when you need a + * value the theme does not expose (e.g. a specific surface layer for a custom + * gradient or the jersey/hero accents). + */ + +/** Brand hues. Orange is the single accent; navy is the secondary chrome hue. */ +export const brand = { + orange: '#FF5A1F', + orangeLight: '#FF8A50', + orangeDark: '#C43E00', + /** Near-black ink for labels on filled orange (AA-safe, ~5.6:1). */ + orangeInk: '#0B0F17', + navy: '#0F172A', + navyLight: '#1E293B', + /** The club's championship gold — used for champions, podium and finals. */ + gold: '#E6A817', + goldLight: '#F5C542', +} as const; + +/** + * Playoff qualification tier colors (HU-45), used to highlight the standings + * rows that qualify to each cup. Gold-silver-bronze for the top three cups, + * then the brand orange for any further cup below the podium three. Silver and + * bronze are muted metallics tuned to stay legible on the dark canvas. + */ +export const cupTier = { + gold: brand.gold, + silver: '#C7CDD6', + bronze: '#CD8E5A', + accent: brand.orange, +} as const; + +/** + * Category accent colors taken from the club's own branding: masculine pieces + * are the warm orange, feminine pieces a vivid purple/magenta. Used to tint + * category chips and the masculine/feminine sections so a visitor tells them + * apart at a glance. + */ +export const category = { + masculine: brand.orange, + feminine: '#A32CC4', +} as const; + +/** + * Layered dark surfaces (canvas -> paper -> raised). A deliberate three-step + * scale so depth reads through elevation, never through a colored overlay. + */ +export const surface = { + canvas: '#111827', // L0 app canvas + paper: '#1A2232', // L1 cards, drawers, app surfaces + raised: '#232D3F', // L2 inputs, menus, hovered rows +} as const; + +/** Light-mode surfaces, retained for the legacy light branch of the theme. */ +export const surfaceLight = { + canvas: '#F4F6F9', + paper: '#FFFFFF', + raised: '#FFFFFF', +} as const; + +export const ink = { + primary: '#E7EAF0', + secondary: '#98A2B3', + primaryLight: brand.navy, + secondaryLight: '#516072', +} as const; + +/** Semantic hues tuned to stay legible on the dark canvas. */ +export const semantic = { + success: '#00C853', + warning: '#F5A524', + info: '#38BDF8', + error: '#d32f2f', +} as const; + +export const dividerColor = { + dark: 'rgba(231, 234, 240, 0.12)', + light: 'rgba(15, 23, 42, 0.12)', +} as const; + +/** + * The Club 12 logo asset bakes its dark maroon backdrop into the PNG (no alpha), + * so surfaces wrapping the logo use this matching color to read as a badge. + */ +export const logoBackground = '#4D0000'; + +/** The SweetAlert cancel affordance color, reused for destructive controls. */ +export const cancelColor = '#d33'; + +/** Corner radii, in px, as a small deliberate scale. */ +export const radius = { + sm: 6, + md: 8, + lg: 10, + xl: 16, + pill: 999, +} as const; + +/** Base spacing unit (px). The MUI `spacing()` factor stays at the default 8. */ +export const spacingUnit = 8; + +/** Typeface roles. Oswald (condensed, uppercase) carries the sporting display + * voice; Roboto handles body copy and data. */ +export const font = { + display: "'Oswald', sans-serif", + body: "'Roboto', sans-serif", +} as const; + +/** + * Constant reserved height for a page's main content area, so a view is the + * same height while its data loads (skeleton) and once it arrives — no layout + * jump. Sized to fill the viewport below the public header and above the + * footer without forcing a scroll on an empty page. + */ +export const pageMinHeight = 'calc(100vh - 220px)'; diff --git a/Club12-WebClient/src/g-loot-react-tournament-brackets.d.ts b/Club12-WebClient/src/g-loot-react-tournament-brackets.d.ts new file mode 100644 index 0000000..2ba8007 --- /dev/null +++ b/Club12-WebClient/src/g-loot-react-tournament-brackets.d.ts @@ -0,0 +1,12 @@ +/** + * `@g-loot/react-tournament-brackets@1.0.31-rc`'s `package.json` declares + * `"types": "dist/index.d.ts"`, but that file doesn't exist in the + * published package (only `dist/esm/index.d.ts` and `dist/cjs/index.d.ts` + * do) — a packaging bug in this pre-release build. This ambient + * declaration re-points the bare specifier at the real declaration file so + * the rest of the app can `import ... from '@g-loot/react-tournament-brackets'` + * normally. Safe to delete once a release fixes the `types` field upstream. + */ +declare module '@g-loot/react-tournament-brackets' { + export * from '@g-loot/react-tournament-brackets/dist/esm/index'; +} diff --git a/Club12-WebClient/src/index.css b/Club12-WebClient/src/index.css new file mode 100644 index 0000000..7498493 --- /dev/null +++ b/Club12-WebClient/src/index.css @@ -0,0 +1,37 @@ +body{ + margin: 0 !important; +} + +/* The Quill rich-text editor's "snow" theme hardcodes toolbar icon colors + for a light background; the app is dark-only, so override them here + since Quill's classes aren't reachable through the MUI theme. */ +.ql-snow .ql-stroke { + stroke: #b8bfc9 !important; +} + +.ql-snow .ql-fill, +.ql-snow .ql-stroke.ql-fill { + fill: #b8bfc9 !important; +} + +.ql-snow .ql-picker { + color: #b8bfc9 !important; +} + +.ql-snow .ql-picker-options { + background-color: #1a2232 !important; + color: #b8bfc9 !important; +} + +.ql-toolbar.ql-snow { + border-color: rgba(231, 234, 240, 0.12) !important; +} + +.ql-container.ql-snow { + border-color: rgba(231, 234, 240, 0.12) !important; + color: #e7eaf0 !important; +} + +.ql-snow .ql-picker.ql-expanded .ql-picker-label { + border-color: rgba(231, 234, 240, 0.12) !important; +} \ No newline at end of file diff --git a/Club12-WebClient/src/main.tsx b/Club12-WebClient/src/main.tsx new file mode 100644 index 0000000..929be85 --- /dev/null +++ b/Club12-WebClient/src/main.tsx @@ -0,0 +1,64 @@ +import { ComponentType, ReactNode } from 'react'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; +// Side-effect import: registers the global maintenance banner against +// axiosUtils' onStatusCode(HttpStatus.ServiceUnavailable, ...) registry. +import './modules/core/utils/maintenanceBanner'; +import { AuthProvider } from './modules/auth/context/auth.context'; +import { BrowserRouter } from 'react-router-dom'; +import { ErrorProvider } from './modules/error/context/error.context'; +import { TournamentProvider } from './modules/tournament/context/tournament.context'; +import { VenueProvider } from './modules/venue/context/venue.context'; +import { SeasonProvider } from './modules/season/context/season.context'; +import { TeamProvider } from './modules/team/context/team.context'; +import { ClubProvider } from './modules/club/context/club.context'; +import { UserProvider } from './modules/user/context/user.context'; +import { DivisionProvider } from './modules/division/context/division.context'; +import { PlayerProvider } from './modules/player/context/player.context'; +import { StageProvider } from './modules/stage/context/stage.context'; +import { MatchProvider } from './modules/match/context/match.context'; +import { PlayerSanctionProvider } from './modules/playerSanction/context/playerSanction.context'; +import { PlayerStatisticProvider } from './modules/playerStatistic/context/playerStatistic.context'; +import { ScorerProvider } from './modules/scorer/context/scorer.context'; +import { BlogPostProvider } from './modules/blogPost/context/blogPost.context'; +import { MedicalRecordProvider } from './modules/medicalRecord/context/medicalRecord.context'; +import { AuditLogProvider } from './modules/auditLog/context/auditLog.context'; +import ErrorBoundary from './views/core/errors/error-boundary'; +import ComposeProviders from './views/core/components/ComposeProviders'; +import QueryProvider from './views/core/components/QueryProvider'; +import ThemedProvider from './views/core/components/ThemedProvider'; + +const providers: ComponentType<{ children: ReactNode }>[] = [ + ErrorBoundary, + QueryProvider, + ThemedProvider, + BrowserRouter, + ErrorProvider, + AuthProvider, + VenueProvider, + SeasonProvider, + TeamProvider, + ClubProvider, + PlayerProvider, + UserProvider, + TournamentProvider, + DivisionProvider, + StageProvider, + MatchProvider, + PlayerSanctionProvider, + ScorerProvider, + PlayerStatisticProvider, + BlogPostProvider, + MedicalRecordProvider, + AuditLogProvider, +]; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + +); diff --git a/Club12-WebClient/src/modules/auditLog/context/auditLog.context.tsx b/Club12-WebClient/src/modules/auditLog/context/auditLog.context.tsx new file mode 100644 index 0000000..44a792e --- /dev/null +++ b/Club12-WebClient/src/modules/auditLog/context/auditLog.context.tsx @@ -0,0 +1,51 @@ +import React, { createContext, ReactNode, useCallback, useMemo } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { GenericResponsePagination } from '@/modules/core/types/types'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { auditLogService } from '@/modules/auditLog/service/auditLog.service'; +import { + AuditLogFiltered, + IAuditLogContextProps, + IAuditLogResponse, +} from '@/modules/auditLog/type/auditLog'; +import { auditLogKeys } from '@/modules/auditLog/queryKeys'; + +export const AuditLogContext = createContext( + undefined +); + +export const AuditLogProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const queryClient = useQueryClient(); + const handleUnknownError = useUnknownErrorHandler(); + + const getAuditLogs = useCallback( + async ( + filter: AuditLogFiltered + ): Promise | void> => { + try { + const response = await queryClient.fetchQuery({ + queryKey: auditLogKeys.list(filter), + queryFn: async () => await auditLogService.getAuditLogs(filter), + }); + + return response?.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const container: IAuditLogContextProps = useMemo( + () => ({ getAuditLogs }), + [getAuditLogs] + ); + + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/auditLog/hook/auditLog.hook.ts b/Club12-WebClient/src/modules/auditLog/hook/auditLog.hook.ts new file mode 100644 index 0000000..79692b5 --- /dev/null +++ b/Club12-WebClient/src/modules/auditLog/hook/auditLog.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { AuditLogContext } from '@/modules/auditLog/context/auditLog.context'; + +export const useAuditLog = () => { + const context = useContext(AuditLogContext); + if (!context) { + throw new Error('useAuditLog must be used within an AuditLogProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/auditLog/queryKeys.ts b/Club12-WebClient/src/modules/auditLog/queryKeys.ts new file mode 100644 index 0000000..8bf268b --- /dev/null +++ b/Club12-WebClient/src/modules/auditLog/queryKeys.ts @@ -0,0 +1,8 @@ +import { AuditLogFiltered } from '@/modules/auditLog/type/auditLog'; + +export const auditLogKeys = { + list: (filter?: AuditLogFiltered) => + filter === undefined + ? (['auditLog', 'list'] as const) + : (['auditLog', 'list', filter] as const), +}; diff --git a/Club12-WebClient/src/modules/auditLog/service/auditLog.service.ts b/Club12-WebClient/src/modules/auditLog/service/auditLog.service.ts new file mode 100644 index 0000000..78ca1dd --- /dev/null +++ b/Club12-WebClient/src/modules/auditLog/service/auditLog.service.ts @@ -0,0 +1,27 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GenericResponsePagination } from '@/modules/core/types/types'; +import { sendGet } from '@/modules/core/utils/axiosUtils'; +import { + AuditLogFiltered, + IAuditLogResponse, +} from '@/modules/auditLog/type/auditLog'; + +/** + * Read-only service for the sensitive-action audit trail (HU-101). + */ +export const auditLogService = { + /** + * Fetches audit entries (newest first) with pagination and optional filters. + * @param {AuditLogFiltered} filter - The filter criteria to apply. + * @returns {Promise>>} + */ + getAuditLogs: async ( + filter: AuditLogFiltered + ): Promise>> => + sendGet>( + routes.auditLogs, + withTablePageSize(filter) + ), +}; diff --git a/Club12-WebClient/src/modules/auditLog/type/auditLog.d.ts b/Club12-WebClient/src/modules/auditLog/type/auditLog.d.ts new file mode 100644 index 0000000..7e46112 --- /dev/null +++ b/Club12-WebClient/src/modules/auditLog/type/auditLog.d.ts @@ -0,0 +1,76 @@ +import { + Filtered, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; + +/** + * The sensitive, auditable actions tracked by the backend (HU-101). Persisted + * as the enum name, so the frontend receives these exact strings. + */ +export type AuditAction = + | 'DataWipe' + | 'BackupRestore' + | 'TournamentStatusChange' + | 'PasswordReset' + | 'PlayoffDraw'; + +/** + * A single audit-trail entry as returned by `GET /api/audit-logs` (HU-101). + * @interface IAuditLogResponse + */ +export interface IAuditLogResponse { + /** The unique identifier of the audit entry. */ + id: GUID; + + /** The sensitive action that was performed (enum name). */ + action: string; + + /** Who performed the action (email, or "System"). */ + actor: string; + + /** The kind of entity targeted, when applicable. */ + targetType?: string | null; + + /** Identifier of the targeted entity, when applicable. */ + targetId?: string | null; + + /** + * The target's human-readable name/label at the moment the action was + * performed. Null for actions with no single named target, or entries + * written before this field existed (fall back to targetId for those). + */ + targetName?: string | null; + + /** Free-form human-readable context. */ + detail?: string | null; + + /** When the action happened (UTC). */ + timestamp: string; +} + +/** + * Filtering and pagination for the audit-trail listing (HU-101). + * @interface AuditLogFiltered + */ +export interface AuditLogFiltered extends Filtered { + /** Optional filter by the actor (who performed the action). */ + actor?: string; + + /** Optional filter by the action type. */ + action?: AuditAction; +} + +/** + * Context surface for reading the audit trail (HU-101). + * @interface IAuditLogContextProps + */ +export interface IAuditLogContextProps { + /** + * Fetches audit entries (newest first) with pagination and optional filters. + * @param filter The filter criteria to apply. + */ + getAuditLogs( + filter: AuditLogFiltered + ): Promise | void>; +} diff --git a/Club12-WebClient/src/modules/auth/context/auth.context.test.tsx b/Club12-WebClient/src/modules/auth/context/auth.context.test.tsx new file mode 100644 index 0000000..68725db --- /dev/null +++ b/Club12-WebClient/src/modules/auth/context/auth.context.test.tsx @@ -0,0 +1,60 @@ +import { act, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import Swal from 'sweetalert2'; +import { ErrorProvider } from '@/modules/error/context/error.context'; +import { AuthProvider } from '@/modules/auth/context/auth.context'; +import { useAuth } from '@/modules/auth/hook/auth.hook'; +import { authService } from '@/modules/auth/service/auth.service'; +import { ERROR_MESSAGES } from '@/modules/core/constants/constants'; + +vi.mock('@/modules/auth/service/auth.service'); +vi.mock('sweetalert2', () => ({ + default: { + fire: vi.fn(), + getContainer: vi.fn().mockReturnValue(null), + }, +})); + +const mockedLoginRequest = vi.mocked(authService.loginRequest); +const mockedSwalFire = vi.mocked(Swal.fire); + +const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('AuthProvider — signIn failure', () => { + it('shows exactly one Spanish toast and resolves false, never the raw backend error', async () => { + mockedLoginRequest.mockRejectedValueOnce( + new Error('Invalid credentials.') + ); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + let success: boolean | undefined; + await act(async () => { + success = await result.current.signIn({ + email: 'wrong@club12.test', + password: 'wrong', + }); + }); + + expect(success).toBe(false); + expect(mockedSwalFire).toHaveBeenCalledTimes(1); + expect(mockedSwalFire).toHaveBeenCalledWith( + expect.objectContaining({ + icon: 'error', + title: ERROR_MESSAGES.LOGIN_FAILED, + }) + ); + }); +}); diff --git a/Club12-WebClient/src/modules/auth/context/auth.context.tsx b/Club12-WebClient/src/modules/auth/context/auth.context.tsx new file mode 100644 index 0000000..73cf3ac --- /dev/null +++ b/Club12-WebClient/src/modules/auth/context/auth.context.tsx @@ -0,0 +1,335 @@ +import { AxiosError } from 'axios'; +import Cookies from 'js-cookie'; +import { decodeToken } from 'react-jwt'; +import React, { + createContext, + useRef, + useState, + useEffect, + useCallback, + useMemo, +} from 'react'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { ProviderProps } from '@/modules/core/types/types'; +import { useError } from '@/modules/error/hooks/error.hock'; +import { authService } from '@/modules/auth/service/auth.service'; +import { + AuthResponse, + IAuthContextProps, + IUser, + LogInUserRequest, +} from '@/modules/auth/type/auth'; + +import { + COOKIE_SIGNIN_TOKEN, + ERROR_MESSAGES, + SUCCESS_MESSAGES, + EXPIRATION_TIME, + JWT, +} from '@/modules/core/constants/constants'; +import { UserRolesType } from '@/modules/core/enum/user/userRolesType'; +import { authKeys } from '@/modules/auth/queryKeys'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +export const AuthContext = createContext( + undefined +); + +const ROLE_CLAIM = + 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role'; +const ROLE_CLAIM_LEGACY = + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role'; + +const ROLE_NORMALIZATION_MAP: Record = { + admin: UserRolesType.Admin, + administrador: UserRolesType.Admin, + owner: UserRolesType.Owner, + duenio: UserRolesType.Owner, + dueño: UserRolesType.Owner, + guest: UserRolesType.Guest, +}; + +const parseExpiresInToMs = (expiresIn: string): number => { + const [dayPart, timePart] = expiresIn.includes('.') + ? expiresIn.split('.') + : [undefined, expiresIn]; + const [hours = 0, minutes = 0, seconds = 0] = timePart.split(':').map(Number); + const days = dayPart ? Number(dayPart) : 0; + + if ([days, hours, minutes, seconds].some(Number.isNaN)) { + return 0; + } + + return ( + days * 24 * EXPIRATION_TIME.MS_IN_HOUR + + hours * EXPIRATION_TIME.MS_IN_HOUR + + minutes * EXPIRATION_TIME.MS_IN_MINUTE + + seconds * EXPIRATION_TIME.MS_IN_SECOND + ); +}; + +const getUserRoleFromToken = (accessToken: string): UserRolesType => { + const payload = decodeToken>(accessToken); + if (!payload) { + return UserRolesType.Guest; + } + + const explicitRoleClaims = [ + payload[ROLE_CLAIM], + payload[ROLE_CLAIM_LEGACY], + payload.role, + payload.roles, + payload.Role, + payload.Roles, + ]; + + const dynamicRoleClaims = Object.entries(payload) + .filter(([key]) => key.toLowerCase().includes('role')) + .map(([, value]) => value); + + const rawRoleValues = [...explicitRoleClaims, ...dynamicRoleClaims].flatMap( + value => (Array.isArray(value) ? value : [value]) + ); + + const normalizedRole = rawRoleValues + .filter((value): value is string => typeof value === 'string') + .flatMap(value => value.split(',')) + .map(value => value.trim().toLowerCase()) + .find(value => Boolean(ROLE_NORMALIZATION_MAP[value])); + + return normalizedRole + ? ROLE_NORMALIZATION_MAP[normalizedRole] + : UserRolesType.Guest; +}; + +export const AuthProvider: React.FC = ({ children }) => { + // Both `user` (which drives `role`) and `isAuthenticated` are seeded + // synchronously from the cookie so route guards never see a false + // "not authenticated" / wrong-role flash on first render or hard reload + // while the async `hasToken` query below is still settling. That flash + // was pushing an extra /login or /forbidden history entry via + // PrivateRoute, breaking the browser back button. + const [user, setUser] = useState(() => { + const token = Cookies.get(COOKIE_SIGNIN_TOKEN); + if (!token) { + return null; + } + + return { + username: '', + accessToken: { accessToken: token, expiresIn: '00:00:00', refreshToken: null }, + role: getUserRoleFromToken(token), + }; + }); + const [isAuthenticated, setIsAuthenticated] = useState(() => + Boolean(Cookies.get(COOKIE_SIGNIN_TOKEN)) + ); + const authTimeoutRef = useRef | null>(null); + const { setError, setMessage } = useError(); + const queryClient = useQueryClient(); + + const { data: hasToken } = useQuery({ + queryKey: authKeys.hasToken(), + queryFn: async () => Boolean(Cookies.get(COOKIE_SIGNIN_TOKEN)), + staleTime: Infinity, + initialData: () => Boolean(Cookies.get(COOKIE_SIGNIN_TOKEN)), + }); + + const signInMutation = useMutation({ + mutationFn: authService.loginRequest, + }); + + const logOutMutation = useMutation({ + mutationFn: authService.logoutRequest, + }); + + const refreshTokenMutation = useMutation({ + mutationFn: authService.refreshTokenRequest, + }); + + const clearAuthTimeout = useCallback(() => { + if (authTimeoutRef.current) { + clearTimeout(authTimeoutRef.current); + authTimeoutRef.current = null; + } + }, []); + + const clearAuthStorage = useCallback(() => { + Cookies.remove(COOKIE_SIGNIN_TOKEN); + localStorage.removeItem(JWT.REFRESH_TOKEN); + setIsAuthenticated(false); + queryClient.setQueryData(authKeys.hasToken(), false); + }, [queryClient]); + + const applyAuthData = useCallback( + (authData: AuthResponse, username?: string) => { + const userRole = getUserRoleFromToken(authData.accessToken); + const expiresInMs = parseExpiresInToMs(authData.expiresIn); + + setUser(prevUser => ({ + username: username ?? prevUser?.username ?? '', + accessToken: authData, + role: userRole, + })); + + Cookies.set( + COOKIE_SIGNIN_TOKEN, + authData.accessToken, + expiresInMs > 0 + ? { expires: expiresInMs / (1000 * 60 * 60 * 24) } + : undefined + ); + + if (authData.refreshToken) { + localStorage.setItem(JWT.REFRESH_TOKEN, authData.refreshToken); + } else { + localStorage.removeItem(JWT.REFRESH_TOKEN); + } + + setIsAuthenticated(true); + queryClient.setQueryData(authKeys.hasToken(), true); + + return expiresInMs; + }, + [queryClient] + ); + + const refreshAuthToken = useCallback(async (): Promise => { + const refreshToken = localStorage.getItem(JWT.REFRESH_TOKEN); + if (!refreshToken) { + clearAuthTimeout(); + clearAuthStorage(); + return false; + } + + try { + const res = await refreshTokenMutation.mutateAsync({ refreshToken }); + if (res?.status === HttpStatus.Ok && res?.data) { + const expiresInMs = applyAuthData(res.data as AuthResponse); + + clearAuthTimeout(); + if (expiresInMs > 0) { + authTimeoutRef.current = setTimeout(() => { + void refreshAuthToken(); + }, expiresInMs); + } + + return true; + } + } catch (error: unknown) { + setError(error as AxiosError); + } + + clearAuthTimeout(); + clearAuthStorage(); + setUser(null); + return false; + }, [ + refreshTokenMutation, + applyAuthData, + clearAuthStorage, + clearAuthTimeout, + setError, + ]); + + useEffect(() => { + setIsAuthenticated(Boolean(hasToken)); + }, [hasToken]); + + const signIn = useCallback( + async (userData: LogInUserRequest): Promise => { + try { + const res = await signInMutation.mutateAsync(userData); + if (res?.status === HttpStatus.Ok && res?.data) { + const authData = res.data as AuthResponse; + const expiresInMs = applyAuthData(authData, userData.email); + + clearAuthTimeout(); + if (expiresInMs > 0) { + authTimeoutRef.current = setTimeout(() => { + void refreshAuthToken(); + }, expiresInMs); + } + + setMessage(res.status, [SUCCESS_MESSAGES.LOGIN_SUCCESS]); + return true; + } + } catch { + // Fire the one standard Spanish toast (same as every other flow) and + // return false so the caller doesn't also show its own message. We + // deliberately do NOT surface the raw API error text here — that's + // the backend's English "Invalid credentials.", not this message. + setMessage(HttpStatus.Unauthorized, [ERROR_MESSAGES.LOGIN_FAILED]); + return false; + } + return false; + }, + [ + setMessage, + signInMutation, + applyAuthData, + clearAuthTimeout, + refreshAuthToken, + ] + ); + + const logOut = useCallback(async () => { + try { + await logOutMutation.mutateAsync(); + clearAuthTimeout(); + clearAuthStorage(); + setUser(null); + } catch (error: unknown) { + setError(error as AxiosError); + } + }, [clearAuthStorage, clearAuthTimeout, setUser, setError, logOutMutation]); + + useEffect(() => { + return () => { + clearAuthTimeout(); + }; + }, [clearAuthTimeout]); + + useEffect(() => { + if (!hasToken) { + return; + } + + const token = Cookies.get(COOKIE_SIGNIN_TOKEN); + if (!token) { + return; + } + + setUser(prevUser => { + if (prevUser?.accessToken?.accessToken === token) { + return prevUser; + } + + const role = getUserRoleFromToken(token); + return { + username: prevUser?.username ?? '', + accessToken: { + accessToken: token, + expiresIn: prevUser?.accessToken?.expiresIn ?? '00:00:00', + refreshToken: prevUser?.accessToken?.refreshToken ?? null, + }, + role, + }; + }); + }, [hasToken]); + + const contextValue = useMemo( + () => ({ + signIn, + logOut, + user, + isAuthenticated, + role: user?.role ?? UserRolesType.Guest, + }), + [signIn, logOut, user, isAuthenticated] + ); + + return ( + {children} + ); +}; diff --git a/Club12-WebClient/src/modules/auth/hook/auth.hook.ts b/Club12-WebClient/src/modules/auth/hook/auth.hook.ts new file mode 100644 index 0000000..8cbe4d2 --- /dev/null +++ b/Club12-WebClient/src/modules/auth/hook/auth.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { AuthContext } from '@/modules/auth/context/auth.context'; + +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used whithin an Auth Provider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/auth/queryKeys.test.ts b/Club12-WebClient/src/modules/auth/queryKeys.test.ts new file mode 100644 index 0000000..15d5499 --- /dev/null +++ b/Club12-WebClient/src/modules/auth/queryKeys.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import { authKeys } from './queryKeys'; + +describe('authKeys', () => { + it('hasToken() returns the singleton literal', () => { + expect(authKeys.hasToken()).toEqual(['auth', 'has-token']); + }); +}); diff --git a/Club12-WebClient/src/modules/auth/queryKeys.ts b/Club12-WebClient/src/modules/auth/queryKeys.ts new file mode 100644 index 0000000..f0b1c02 --- /dev/null +++ b/Club12-WebClient/src/modules/auth/queryKeys.ts @@ -0,0 +1,3 @@ +export const authKeys = { + hasToken: () => ['auth', 'has-token'] as const, +}; diff --git a/Club12-WebClient/src/modules/auth/service/auth.service.test.ts b/Club12-WebClient/src/modules/auth/service/auth.service.test.ts new file mode 100644 index 0000000..807d8f3 --- /dev/null +++ b/Club12-WebClient/src/modules/auth/service/auth.service.test.ts @@ -0,0 +1,51 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { authService } from '@/modules/auth/service/auth.service'; +import { sendPost } from '@/modules/core/utils/axiosUtils'; + +vi.mock('@/modules/core/utils/axiosUtils', () => ({ + sendPost: vi.fn(() => Promise.resolve({ status: 200, data: {} })), +})); + +const sendPostMock = vi.mocked(sendPost); + +describe('authService magic-link endpoints (HU-09/HU-10)', () => { + afterEach(() => { + sendPostMock.mockClear(); + }); + + it('inviteRequest posts email + role to auth/invite', async () => { + await authService.inviteRequest({ + email: 'nuevo@club12.com', + role: 'ADMIN', + }); + + expect(sendPostMock).toHaveBeenCalledWith('auth/invite', { + email: 'nuevo@club12.com', + role: 'ADMIN', + }); + }); + + it('activateRequest posts email + token + newPassword to auth/activate', async () => { + await authService.activateRequest({ + email: 'nuevo@club12.com', + token: 'activation-token', + newPassword: 'Str0ng!Pass', + }); + + expect(sendPostMock).toHaveBeenCalledWith('auth/activate', { + email: 'nuevo@club12.com', + token: 'activation-token', + newPassword: 'Str0ng!Pass', + }); + }); + + it('requestPasswordResetRequest posts email to auth/password-reset/request', async () => { + await authService.requestPasswordResetRequest({ + email: 'olvide@club12.com', + }); + + expect(sendPostMock).toHaveBeenCalledWith('auth/password-reset/request', { + email: 'olvide@club12.com', + }); + }); +}); diff --git a/Club12-WebClient/src/modules/auth/service/auth.service.ts b/Club12-WebClient/src/modules/auth/service/auth.service.ts new file mode 100644 index 0000000..dbe951c --- /dev/null +++ b/Club12-WebClient/src/modules/auth/service/auth.service.ts @@ -0,0 +1,59 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { sendPost } from '@/modules/core/utils/axiosUtils'; +import { + ActivateAccountRequest, + AuthResponse, + InviteUserRequest, + InviteUserResponse, + LogInUserRequest, + PasswordResetConfirmRequest, + RefreshTokenRequest, + RequestPasswordResetRequest, +} from '@/modules/auth/type/auth'; + +export const authService = { + loginRequest: ( + user: LogInUserRequest + ): Promise | undefined> => + sendPost(`${routes.auth}/login`, user), + + refreshTokenRequest: ( + refreshToken: RefreshTokenRequest + ): Promise | undefined> => + sendPost(`${routes.auth}/refresh-token`, refreshToken), + + /** + * HU-09: invites a user by email (Admin/Owner). The backend creates a + * passwordless account and emails a magic activation link. + */ + inviteRequest: ( + payload: InviteUserRequest + ): Promise | undefined> => + sendPost(`${routes.auth}/invite`, payload), + + /** + * HU-09: consumes the activation token from the invitation email and sets + * the invited user's first password. + */ + activateRequest: ( + payload: ActivateAccountRequest + ): Promise | undefined> => + sendPost(`${routes.auth}/activate`, payload), + + /** + * HU-10: self-service. Requests a password-reset magic link for the given + * email. Always resolves 200 (no account enumeration). + */ + requestPasswordResetRequest: ( + payload: RequestPasswordResetRequest + ): Promise | undefined> => + sendPost(`${routes.auth}/password-reset/request`, payload), + + confirmPasswordResetRequest: ( + payload: PasswordResetConfirmRequest + ): Promise | undefined> => + sendPost(`${routes.auth}/password-reset/confirm`, payload), + + logoutRequest: () => sendPost(`${routes.auth}/logout`), +}; diff --git a/Club12-WebClient/src/modules/auth/type/auth.d.ts b/Club12-WebClient/src/modules/auth/type/auth.d.ts new file mode 100644 index 0000000..f49a58c --- /dev/null +++ b/Club12-WebClient/src/modules/auth/type/auth.d.ts @@ -0,0 +1,235 @@ +import { UserRolesType } from '@/modules/core/enum/user/userRolesType'; + +/** + * Represents the response object for authentication tokens. + * @interface TokenResponse + */ +export interface TokenResponse { + /** + * The access token used for authentication. + * @type {string | null} + */ + accessToken: string; + + /** + * The expiration duration of the access token in date-span format. + * @type {string} + */ + expiresIn: string; + + /** + * The refresh token used for refreshing the access token. + * @type {string | null} + */ + refreshToken: string | null; +} + +/** + * Represents the request object to refresh the access token using the refresh token. + * @interface RefreshTokenRequest + */ +export interface RefreshTokenRequest { + /** + * The refresh token used to get a new access token. + * @type {string} + */ + refreshToken: string; +} + +/** + * Represents a password reset confirmation request from an email link. + * @interface PasswordResetConfirmRequest + */ +export interface PasswordResetConfirmRequest { + /** + * Email associated with the user account. + * @type {string} + */ + email: string; + + /** + * Token received by email for password reset. + * @type {string} + */ + token: string; + + /** + * New password to set. + * @type {string} + */ + newPassword: string; +} + +/** + * HU-09: request to invite a user by email only (no password). The backend + * creates a passwordless account and emails a magic activation link. Requires + * Admin or Owner. + * @interface InviteUserRequest + */ +export interface InviteUserRequest { + /** + * Email the invitation/activation link is sent to. + * @type {string} + */ + email: string; + + /** + * Optional contact phone number. + * @type {string | undefined} + */ + phone?: string; + + /** + * Target role. Accepted values: ADMIN, OWNER. + * @type {string} + */ + role: string; +} + +/** + * HU-09: confirmation payload returned after inviting a user by email. + * @interface InviteUserResponse + */ +export interface InviteUserResponse { + /** + * Id of the newly created (passwordless) user. + * @type {string} + */ + userId: string; + + /** + * Email the activation link was sent to. + * @type {string} + */ + email: string; + + /** + * Assigned role. + * @type {string} + */ + role: string; +} + +/** + * HU-09: payload the invited user submits from the activation email link to + * set their first password and enable login. + * @interface ActivateAccountRequest + */ +export interface ActivateAccountRequest { + /** + * Email associated with the invited account. + * @type {string} + */ + email: string; + + /** + * Activation token received by email. + * @type {string} + */ + token: string; + + /** + * First password the user chooses. + * @type {string} + */ + newPassword: string; +} + +/** + * HU-10: self-service request to receive a password-reset magic link by email. + * @interface RequestPasswordResetRequest + */ +export interface RequestPasswordResetRequest { + /** + * Email to send the password-reset link to. + * @type {string} + */ + email: string; +} + +/** + * Represents a user login request. + * @interface LogInUserRequest + */ +export interface LogInUserRequest { + /** + * The username of the user. + * @type {string} + * @minLength 1 + */ + email: string; + + /** + * The password of the user. + * @type {string} + * @minLength 1 + */ + password: string; +} + +/** + * Represents the response containing user authentication information. + * @interface AuthResponse + */ +export type AuthResponse = TokenResponse; + +/** + * Represents the authentication context properties for sign-in, sign-out, and user information. + * @interface IAuthContextProps + */ +export interface IAuthContextProps { + /** + * Sign-in method that attempts to authenticate the user. + * @param value The login credentials. + * @returns {Promise} Whether authentication was successful. + */ + signIn: (value: LogInUserRequest) => Promise; + + /** + * Log out the current user and clear authentication state. + * @returns {Promise} Returns a promise that resolves when logout is complete. + */ + logOut: () => Promise; + + /** + * The current authenticated user or null if no user is logged in. + * @type {IUser | null} + */ + user: IUser | null; + + /** + * Boolean flag indicating whether the user is authenticated. + * @type {boolean} + */ + isAuthenticated: boolean; + + /** + * The role of the current user. + * @type {UserRolesType} + */ + role: UserRolesType; +} + +/** + * Represents the authenticated user details. + * @interface IUser + */ +export interface IUser { + /** + * The username of the user. + * @type {string} + */ + username: string; + + /** + * The authentication response containing the user's access token. + * @type {AuthResponse} + */ + accessToken: AuthResponse; + + /** + * The role of the user. + * @type {UserRolesType} + */ + role: UserRolesType; +} diff --git a/Club12-WebClient/src/modules/auth/utils/passwordPolicy.ts b/Club12-WebClient/src/modules/auth/utils/passwordPolicy.ts new file mode 100644 index 0000000..b9377dd --- /dev/null +++ b/Club12-WebClient/src/modules/auth/utils/passwordPolicy.ts @@ -0,0 +1,95 @@ +/** + * Client-side mirror of the backend password policy (Identity defaults). + * Kept in one place so every password-setting screen (activation, reset, + * change password) validates the same rules and shows the same wording. + */ + +export interface PasswordPolicyState { + requiredLength: boolean; + requireUppercase: boolean; + requireLowercase: boolean; + requireDigit: boolean; + requireNonAlphanumeric: boolean; + requiredUniqueChars: boolean; +} + +export const getPasswordPolicyState = (password: string): PasswordPolicyState => { + const uniqueCharsCount = new Set(password).size; + + return { + requiredLength: password.length >= 8, + requireUppercase: /[A-Z]/.test(password), + requireLowercase: /[a-z]/.test(password), + requireDigit: /\d/.test(password), + requireNonAlphanumeric: /[^a-zA-Z0-9]/.test(password), + requiredUniqueChars: uniqueCharsCount >= 2, + }; +}; + +/** + * Builds the list of human-readable validation messages for a new password and + * its confirmation. Returns an empty array when everything is valid. + */ +export const buildPasswordPolicyMessages = ( + newPassword: string, + confirmPassword: string +): string[] => { + const policy = getPasswordPolicyState(newPassword); + const messages: string[] = []; + + if (!newPassword) { + messages.push('La nueva contraseña es obligatoria.'); + } + + if (!policy.requiredLength) { + messages.push('La contraseña debe tener al menos 8 caracteres.'); + } + + if (!policy.requireUppercase) { + messages.push('La contraseña debe contener al menos una letra mayúscula.'); + } + + if (!policy.requireLowercase) { + messages.push('La contraseña debe contener al menos una letra minúscula.'); + } + + if (!policy.requireDigit) { + messages.push('La contraseña debe contener al menos un número.'); + } + + if (!policy.requireNonAlphanumeric) { + messages.push( + 'La contraseña debe contener al menos un carácter no alfanumérico.' + ); + } + + if (!policy.requiredUniqueChars) { + messages.push('La contraseña debe contener al menos 2 caracteres únicos.'); + } + + if (!confirmPassword) { + messages.push('La confirmación de contraseña es obligatoria.'); + } + + if (newPassword && confirmPassword && newPassword !== confirmPassword) { + messages.push('La confirmación no coincide con la nueva contraseña.'); + } + + return messages; +}; + +/** + * Ordered checklist rendered under the password field so the user sees which + * rules they still need to satisfy. + */ +export const PASSWORD_POLICY_RULES: { + key: keyof PasswordPolicyState; + label: string; +}[] = [ + { key: 'requiredLength', label: 'Mínimo 8 caracteres' }, + { key: 'requireUppercase', label: 'Al menos una mayúscula' }, + { key: 'requireLowercase', label: 'Al menos una minúscula' }, + { key: 'requireDigit', label: 'Al menos un número' }, + { key: 'requireNonAlphanumeric', label: 'Al menos un carácter especial' }, + { key: 'requiredUniqueChars', label: 'Al menos 2 caracteres únicos' }, +]; diff --git a/Club12-WebClient/src/modules/backup/hook/backup.hook.test.ts b/Club12-WebClient/src/modules/backup/hook/backup.hook.test.ts new file mode 100644 index 0000000..6b01fef --- /dev/null +++ b/Club12-WebClient/src/modules/backup/hook/backup.hook.test.ts @@ -0,0 +1,250 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { AxiosError, AxiosResponse } from 'axios'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useBackups } from '@/modules/backup/hook/backup.hook'; +import { backupService } from '@/modules/backup/service/backup.service'; +import type { IBackupRecordResponse } from '@/modules/backup/type/backup'; + +vi.mock('@/modules/backup/service/backup.service'); + +const mockedBackupService = vi.mocked(backupService, true); + +const buildRecord = ( + overrides: Partial = {} +): IBackupRecordResponse => ({ + id: 'guid-1-aaaa-bbbb-cccc', + createdAt: '2026-08-19T10:00:00Z', + sizeBytes: 1024, + origin: 'Manual', + storagePath: 'backup-1.sql', + ...overrides, +}); + +const buildResponse = (data: T, status = 200): AxiosResponse => + ({ + data, + status, + statusText: 'OK', + headers: {}, + config: {}, + }) as AxiosResponse; + +const buildAxiosError = (status: number): AxiosError => + ({ + isAxiosError: true, + name: 'AxiosError', + message: `Request failed with status code ${status}`, + config: {}, + response: buildResponse(undefined, status), + toJSON: () => ({}), + }) as unknown as AxiosError; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('useBackups — fetchBackups', () => { + it('starts with an empty list and not loading', () => { + const { result } = renderHook(() => useBackups()); + + expect(result.current.backups).toEqual([]); + expect(result.current.loading).toBe(false); + }); + + it('sets loading true during the fetch and populates backups on success', async () => { + const records = [buildRecord()]; + let resolveFetch: (value: AxiosResponse) => void = + () => {}; + mockedBackupService.getBackups.mockImplementation( + () => + new Promise(resolve => { + resolveFetch = resolve; + }) + ); + + const { result } = renderHook(() => useBackups()); + + act(() => { + void result.current.fetchBackups(); + }); + + await waitFor(() => expect(result.current.loading).toBe(true)); + + act(() => { + resolveFetch(buildResponse(records)); + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.backups).toEqual(records); + }); + + it('clears loading and leaves backups unchanged when the fetch fails', async () => { + mockedBackupService.getBackups.mockRejectedValueOnce( + buildAxiosError(500) + ); + + const { result } = renderHook(() => useBackups()); + + await act(async () => { + await result.current.fetchBackups(); + }); + + expect(result.current.loading).toBe(false); + expect(result.current.backups).toEqual([]); + }); +}); + +describe('useBackups — createBackup', () => { + it('sets busy during the request, refetches the catalog, and resolves true on success', async () => { + const created = buildRecord({ id: 'guid-new' }); + let resolveCreate: (value: AxiosResponse) => void = + () => {}; + mockedBackupService.createBackup.mockImplementation( + () => + new Promise(resolve => { + resolveCreate = resolve; + }) + ); + // The server applies retention pruning, so the hook must re-read the + // authoritative catalog instead of optimistically prepending the new row. + mockedBackupService.getBackups.mockResolvedValue(buildResponse([created])); + + const { result } = renderHook(() => useBackups()); + + let createPromise: Promise; + act(() => { + createPromise = result.current.createBackup(); + }); + + await waitFor(() => expect(result.current.busy).toBe(true)); + + await act(async () => { + resolveCreate(buildResponse(created)); + await createPromise; + }); + + await expect(createPromise!).resolves.toBe(true); + expect(result.current.busy).toBe(false); + expect(mockedBackupService.getBackups).toHaveBeenCalled(); + expect(result.current.backups).toEqual([created]); + }); + + it('resolves false and leaves the list unchanged when the server is busy (409)', async () => { + mockedBackupService.createBackup.mockRejectedValueOnce( + buildAxiosError(409) + ); + + const { result } = renderHook(() => useBackups()); + + let created: boolean = true; + await act(async () => { + created = await result.current.createBackup(); + }); + + expect(created).toBe(false); + expect(result.current.busy).toBe(false); + expect(result.current.backups).toEqual([]); + }); +}); + +describe('useBackups — deleteBackup', () => { + it('removes the deleted record from the list and resolves true on success', async () => { + const record = buildRecord(); + mockedBackupService.getBackups.mockResolvedValueOnce( + buildResponse([record]) + ); + mockedBackupService.deleteBackup.mockResolvedValueOnce( + buildResponse(undefined, 204) + ); + + const { result } = renderHook(() => useBackups()); + await act(async () => { + await result.current.fetchBackups(); + }); + + let deleted: boolean = false; + await act(async () => { + deleted = await result.current.deleteBackup(record.id); + }); + + expect(deleted).toBe(true); + expect(result.current.backups).toEqual([]); + }); + + it('resolves false and leaves the list unchanged when the record no longer exists (404)', async () => { + const record = buildRecord(); + mockedBackupService.getBackups.mockResolvedValueOnce( + buildResponse([record]) + ); + mockedBackupService.deleteBackup.mockRejectedValueOnce( + buildAxiosError(404) + ); + + const { result } = renderHook(() => useBackups()); + await act(async () => { + await result.current.fetchBackups(); + }); + + let deleted: boolean = true; + await act(async () => { + deleted = await result.current.deleteBackup(record.id); + }); + + expect(deleted).toBe(false); + expect(result.current.backups).toEqual([record]); + }); +}); + +describe('useBackups — restoreBackup', () => { + it('refetches the catalog after a successful restore and resolves true', async () => { + // A restore replays a full-schema dump, so the real catalog reverts to the + // restored snapshot's state — the hook must re-read it, never trust the + // pre-restore safety-backup record the endpoint returns. + const restoredCatalog = [buildRecord({ id: 'guid-from-snapshot' })]; + mockedBackupService.restoreBackup.mockResolvedValueOnce( + buildResponse(buildRecord({ id: 'guid-safety', origin: 'Job' })) + ); + mockedBackupService.getBackups.mockResolvedValueOnce( + buildResponse(restoredCatalog) + ); + + const { result } = renderHook(() => useBackups()); + + let restored: boolean = false; + await act(async () => { + restored = await result.current.restoreBackup('guid-target'); + }); + + expect(restored).toBe(true); + expect(mockedBackupService.getBackups).toHaveBeenCalled(); + expect(result.current.backups).toEqual(restoredCatalog); + }); + + it('sets busy true while in flight and resolves false on failure (500)', async () => { + let rejectRestore: (error: AxiosError) => void = () => {}; + mockedBackupService.restoreBackup.mockImplementation( + () => + new Promise((_, reject) => { + rejectRestore = reject; + }) + ); + + const { result } = renderHook(() => useBackups()); + + let restorePromise: Promise; + act(() => { + restorePromise = result.current.restoreBackup('guid-target'); + }); + + await waitFor(() => expect(result.current.busy).toBe(true)); + + await act(async () => { + rejectRestore(buildAxiosError(500)); + await restorePromise; + }); + + await expect(restorePromise!).resolves.toBe(false); + expect(result.current.busy).toBe(false); + expect(result.current.backups).toEqual([]); + }); +}); diff --git a/Club12-WebClient/src/modules/backup/hook/backup.hook.ts b/Club12-WebClient/src/modules/backup/hook/backup.hook.ts new file mode 100644 index 0000000..042a74c --- /dev/null +++ b/Club12-WebClient/src/modules/backup/hook/backup.hook.ts @@ -0,0 +1,99 @@ +import { useCallback, useState } from 'react'; +import { backupService } from '@/modules/backup/service/backup.service'; +import { IBackupRecordResponse } from '@/modules/backup/type/backup'; + +export interface UseBackupsResult { + backups: IBackupRecordResponse[]; + loading: boolean; + busy: boolean; + fetchBackups: () => Promise; + createBackup: () => Promise; + deleteBackup: (id: string) => Promise; + restoreBackup: (id: string) => Promise; +} + +/** + * Plain state hook (not a context provider) owning the backup catalog for + * the admin panel. `BackupsTable` is currently its only consumer, so a + * shared context/provider would add indirection nothing else needs. + */ +export const useBackups = (): UseBackupsResult => { + const [backups, setBackups] = useState([]); + const [loading, setLoading] = useState(false); + const [busy, setBusy] = useState(false); + + // Pull the authoritative catalog from the server. Errors are swallowed: the + // caller keeps the previous list and page-level notify* handles messaging. + const refreshCatalog = useCallback(async (): Promise => { + try { + const response = await backupService.getBackups(); + setBackups(response.data); + } catch { + // keep the previous list + } + }, []); + + const fetchBackups = useCallback(async (): Promise => { + setLoading(true); + try { + await refreshCatalog(); + } finally { + setLoading(false); + } + }, [refreshCatalog]); + + const createBackup = useCallback(async (): Promise => { + setBusy(true); + try { + await backupService.createBackup(); + // Refetch, not an optimistic prepend: a manual backup applies server-side + // retention pruning, so the new row is not the only change to the catalog. + await refreshCatalog(); + return true; + } catch { + return false; + } finally { + setBusy(false); + } + }, [refreshCatalog]); + + const deleteBackup = useCallback(async (id: string): Promise => { + setBusy(true); + try { + await backupService.deleteBackup(id); + setBackups(prev => prev.filter(backup => backup.id !== id)); + return true; + } catch { + return false; + } finally { + setBusy(false); + } + }, []); + + const restoreBackup = useCallback(async (id: string): Promise => { + setBusy(true); + try { + await backupService.restoreBackup(id); + // Refetch, never optimistic: a restore replays a full-schema dump + // (BackupRecords included), so the catalog reverts to the restored + // snapshot's state — later backups, later deletions and the just-created + // pre-restore safety backup are all gone from the real table. + await refreshCatalog(); + return true; + } catch { + return false; + } finally { + setBusy(false); + } + }, [refreshCatalog]); + + return { + backups, + loading, + busy, + fetchBackups, + createBackup, + deleteBackup, + restoreBackup, + }; +}; diff --git a/Club12-WebClient/src/modules/backup/service/backup.service.ts b/Club12-WebClient/src/modules/backup/service/backup.service.ts new file mode 100644 index 0000000..da3954a --- /dev/null +++ b/Club12-WebClient/src/modules/backup/service/backup.service.ts @@ -0,0 +1,51 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { sendDelete, sendGet, sendPost } from '@/modules/core/utils/axiosUtils'; +import { IBackupRecordResponse } from '@/modules/backup/type/backup'; + +/** + * Admin-only tools for generating, listing, deleting and restoring database + * backups, plus the escape hatch for the maintenance-mode window a restore + * opens. + */ +export const backupService = { + /** + * Retrieves every catalogued backup, newest first. + * @returns {Promise>} The server response. + */ + getBackups: async (): Promise> => + await sendGet(routes.backups), + + /** + * Triggers an on-demand (manual) backup. + * @returns {Promise>} The server response containing the new backup. + */ + createBackup: async (): Promise> => + await sendPost(routes.backups), + + /** + * Deletes a catalogued backup by its ID. + * @param {string} id - The ID of the backup to delete. + * @returns {Promise>} The server response. + */ + deleteBackup: async (id: string): Promise> => + await sendDelete(`${routes.backups}/${id}`), + + /** + * Restores the database from a catalogued backup. The server takes an + * automatic safety backup of the current state first and returns it. + * @param {string} id - The ID of the backup to restore from. + * @returns {Promise>} The server response containing the safety backup. + */ + restoreBackup: async ( + id: string + ): Promise> => + await sendPost(`${routes.backups}/${id}/restore`), + + /** + * Force-exits maintenance mode, in case it is stuck active. + * @returns {Promise>} The server response. + */ + exitMaintenance: async (): Promise> => + await sendDelete(routes.maintenance), +}; diff --git a/Club12-WebClient/src/modules/backup/type/backup.d.ts b/Club12-WebClient/src/modules/backup/type/backup.d.ts new file mode 100644 index 0000000..90878d7 --- /dev/null +++ b/Club12-WebClient/src/modules/backup/type/backup.d.ts @@ -0,0 +1,62 @@ +/** + * A single catalogued backup, as returned by the backend catalog. Mirrors + * `Application/DTOs/Backup/Response/BackupRecordResponse.cs`. + * @interface IBackupRecordResponse + */ +export interface IBackupRecordResponse { + /** + * The unique identifier of the backup record. + * @type {string} + */ + id: string; + + /** + * ISO timestamp of when the backup was created (Fecha). + * @type {string} + */ + createdAt: string; + + /** + * The size of the backup file in bytes (Peso). + * @type {number} + */ + sizeBytes: number; + + /** + * How the backup was created (Forma de creación): a manual on-demand + * request or an automated scheduled job. + * @type {'Manual' | 'Job'} + */ + origin: 'Manual' | 'Job'; + + /** + * The storage key used to locate the backup file. + * @type {string} + */ + storagePath: string; +} + +/** + * Current maintenance-mode state, as returned by the backend. Mirrors + * `Application/DTOs/Backup/Response/MaintenanceStatusResponse.cs`. + * @interface IMaintenanceStatusResponse + */ +export interface IMaintenanceStatusResponse { + /** + * Whether the database is currently in maintenance mode. + * @type {boolean} + */ + isActive: boolean; + + /** + * The reason maintenance mode was entered, if active. + * @type {string | null} + */ + reason: string | null; + + /** + * ISO timestamp of when maintenance mode was entered, if active. + * @type {string | null} + */ + enteredAtUtc: string | null; +} diff --git a/Club12-WebClient/src/modules/backup/utils/backupFormat.ts b/Club12-WebClient/src/modules/backup/utils/backupFormat.ts new file mode 100644 index 0000000..c58bc4c --- /dev/null +++ b/Club12-WebClient/src/modules/backup/utils/backupFormat.ts @@ -0,0 +1,34 @@ +/** + * Spanish display labels for each `IBackupRecordResponse.origin` value, + * shown in the "Forma de creación" table column. + */ +export const BACKUP_ORIGIN_LABELS: Record<'Manual' | 'Job', string> = { + Manual: 'Manual', + Job: 'Programado', +}; + +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const; + +/** + * Formats a byte count into a human-readable string (e.g. `1536` → `1.5 KB`), + * shown in the "Peso" table column. + * @param {number} bytes - The size in bytes. + * @returns {string} The formatted, human-readable size. + */ +export const formatBytes = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes <= 0) { + return '0 B'; + } + + const exponent = Math.min( + Math.floor(Math.log(bytes) / Math.log(1024)), + UNITS.length - 1 + ); + const value = bytes / Math.pow(1024, exponent); + // Round to 1 decimal, then drop a trailing ".0" so whole numbers (e.g. + // exactly 2 KB) read as "2 KB" instead of "2.0 KB". + const formattedValue = + exponent === 0 ? value.toString() : value.toFixed(1).replace(/\.0$/, ''); + + return `${formattedValue} ${UNITS[exponent]}`; +}; diff --git a/Club12-WebClient/src/modules/blogPost/constants/blogPost.ts b/Club12-WebClient/src/modules/blogPost/constants/blogPost.ts new file mode 100644 index 0000000..444bfd0 --- /dev/null +++ b/Club12-WebClient/src/modules/blogPost/constants/blogPost.ts @@ -0,0 +1,7 @@ +export const BLOG_EXCERPT_LENGTH = 150; +export const BLOG_HOME_EXCERPT_LENGTH = 160; + +// Well under the Slug column's 220-char limit (Slug is derived from Title +// and can get a "-2" suffix for uniqueness), so a max-length title can never +// overflow it. +export const BLOG_TITLE_MAX_LENGTH = 150; diff --git a/Club12-WebClient/src/modules/blogPost/context/blogPost.context.test.tsx b/Club12-WebClient/src/modules/blogPost/context/blogPost.context.test.tsx new file mode 100644 index 0000000..820f197 --- /dev/null +++ b/Club12-WebClient/src/modules/blogPost/context/blogPost.context.test.tsx @@ -0,0 +1,53 @@ +import { act, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import Swal from 'sweetalert2'; +import { ErrorProvider } from '@/modules/error/context/error.context'; +import { BlogPostProvider } from '@/modules/blogPost/context/blogPost.context'; +import { useBlogPost } from '@/modules/blogPost/hook/blogPost.hook'; +import { blogPostService } from '@/modules/blogPost/service/blogPost.service'; + +vi.mock('@/modules/blogPost/service/blogPost.service'); +vi.mock('sweetalert2', () => ({ + default: { + fire: vi.fn(), + getContainer: vi.fn().mockReturnValue(null), + }, +})); + +const mockedAddBlogPost = vi.mocked(blogPostService.addBlogPost); +const mockedSwalFire = vi.mocked(Swal.fire); + +const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('BlogPostProvider — no duplicate success toast', () => { + /** + * addBlogPostForm.tsx already shows its own confirmation. The context used + * to ALSO fire a toast ("Blog Post created successfully" — also the only + * English string in these flows), so the user saw two modals for one save. + */ + it('does not fire its own toast after addBlogPost succeeds', async () => { + mockedAddBlogPost.mockResolvedValueOnce({ + status: 201, + data: { id: '77777777-7777-7777-7777-777777777777' }, + } as never); + + const { result } = renderHook(() => useBlogPost(), { wrapper }); + await act(async () => { + await result.current.addBlogPost({} as never); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); +}); diff --git a/Club12-WebClient/src/modules/blogPost/context/blogPost.context.tsx b/Club12-WebClient/src/modules/blogPost/context/blogPost.context.tsx new file mode 100644 index 0000000..3770bfa --- /dev/null +++ b/Club12-WebClient/src/modules/blogPost/context/blogPost.context.tsx @@ -0,0 +1,207 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import React, { createContext, ReactNode, useCallback, useMemo } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + FetchOptions, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { blogPostService } from '@/modules/blogPost/service/blogPost.service'; +import { + BlogPostResponse, + CreateBlogPostRequest, + GetBlogPostsFilteredRequest, + IBlogPostContextProps, + UpdateBlogPostRequest, +} from '@/modules/blogPost/type/blogPost'; +import { blogPostKeys } from '@/modules/blogPost/queryKeys'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +export const BlogPostContext = createContext( + undefined +); + +export const BlogPostProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addBlogPostMutation = useMutation({ + mutationFn: blogPostService.addBlogPost, + }); + + const putBlogPostMutation = useMutation({ + mutationFn: ({ id, post }: { id: GUID; post: UpdateBlogPostRequest }) => + blogPostService.putBlogPostById(id, post), + }); + + const putPhotoBlogPostMutation = useMutation({ + mutationFn: ({ id, photo }: { id: GUID; photo: File }) => + blogPostService.putPhotoBlogPostById(id, photo), + }); + + const deleteBlogPostMutation = useMutation({ + mutationFn: blogPostService.deleteBlogPostById, + }); + + const addBlogPost = useCallback( + async (post: CreateBlogPostRequest): Promise => { + try { + const response: AxiosResponse = + await addBlogPostMutation.mutateAsync(post); + + if (response && response.data) { + // Success feedback belongs to the calling page (addBlogPostForm.tsx + // shows its own confirmation) — a toast here too means two modals. + queryClient.setQueryData( + blogPostKeys.byId(response.data.id), + response + ); + await queryClient.invalidateQueries({ + queryKey: blogPostKeys.list(), + }); + return response.data; + } + + throw new AxiosError( + 'Respuesta del servidor inválida.', + undefined, + undefined, + response + ); + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addBlogPostMutation, queryClient, handleUnknownError] + ); + + /** + * Updates a blog post by id. When the response is 204 No Content there is + * no body to cache — the invalidation below is enough to refresh callers. + */ + const putBlogPostById = useCallback( + async ( + id: GUID, + post: UpdateBlogPostRequest + ): Promise => { + try { + const response = await putBlogPostMutation.mutateAsync({ id, post }); + if (response) { + if (response.status === HttpStatus.NoContent) { + // no-op + } else if (response.data) { + queryClient.setQueryData(blogPostKeys.byId(id), response); + return response.data; + } + await queryClient.invalidateQueries({ + queryKey: blogPostKeys.list(), + }); + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putBlogPostMutation, queryClient, handleUnknownError] + ); + + const putPhotoBlogPostById = useCallback( + async (id: GUID, photo: File): Promise => { + try { + await putPhotoBlogPostMutation.mutateAsync({ id, photo }); + // The photo endpoint returns no body and each upload lands at a new + // unique URL, so the fresh photoUrl is only knowable via a real GET — + // mirrors venue.context.tsx's putVenuePhotoById fix for the same gap. + const res: AxiosResponse = + await blogPostService.getBlogPostsById(id); + queryClient.setQueryData(blogPostKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: blogPostKeys.list() }); + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putPhotoBlogPostMutation, queryClient, handleUnknownError] + ); + + const getBlogPostsById = useCallback( + async ( + idOrSlug: string, + options?: FetchOptions + ): Promise => { + try { + const response = await queryClient.fetchQuery({ + queryKey: blogPostKeys.byId(idOrSlug), + queryFn: async () => await blogPostService.getBlogPostsById(idOrSlug), + }); + + return response?.data; + } catch (error: unknown) { + if (!options?.silent) handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const getBlogPostsByFilters = useCallback( + async ( + filter: GetBlogPostsFilteredRequest, + options?: FetchOptions + ): Promise | void> => { + try { + const response = await queryClient.fetchQuery({ + queryKey: blogPostKeys.list(filter), + queryFn: async () => + await blogPostService.getBlogPostsByFilters(filter), + }); + + return response?.data; + } catch (error: unknown) { + if (!options?.silent) handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const deleteBlogPostById = useCallback( + async (id: GUID): Promise => { + try { + await deleteBlogPostMutation.mutateAsync(id); + queryClient.removeQueries({ queryKey: blogPostKeys.byId(id) }); + await queryClient.invalidateQueries({ queryKey: blogPostKeys.list() }); + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [deleteBlogPostMutation, queryClient, handleUnknownError] + ); + + const container: IBlogPostContextProps = useMemo( + () => ({ + addBlogPost, + putBlogPostById, + putPhotoBlogPostById, + getBlogPostsById, + getBlogPostsByFilters, + deleteBlogPostById, + }), + [ + addBlogPost, + putBlogPostById, + putPhotoBlogPostById, + getBlogPostsById, + getBlogPostsByFilters, + deleteBlogPostById, + ] + ); + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/blogPost/hook/blogPost.hook.ts b/Club12-WebClient/src/modules/blogPost/hook/blogPost.hook.ts new file mode 100644 index 0000000..2c84edf --- /dev/null +++ b/Club12-WebClient/src/modules/blogPost/hook/blogPost.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { BlogPostContext } from '@/modules/blogPost/context/blogPost.context'; + +export const useBlogPost = () => { + const context = useContext(BlogPostContext); + if (!context) { + throw new Error('useBlogPost must be used within a BlogPostProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/blogPost/queryKeys.test.ts b/Club12-WebClient/src/modules/blogPost/queryKeys.test.ts new file mode 100644 index 0000000..04aeb5f --- /dev/null +++ b/Club12-WebClient/src/modules/blogPost/queryKeys.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { blogPostKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; +import { GetBlogPostsFilteredRequest } from '@/modules/blogPost/type/blogPost'; + +describe('blogPostKeys', () => { + const id: GUID = '11111111-1111-1111-1111-111111111111'; + + it('list() returns the bare list literal with no trailing undefined', () => { + expect(blogPostKeys.list()).toEqual(['blogPost', 'list']); + }); + + it('list(filter) returns the filtered list literal', () => { + const filter: GetBlogPostsFilteredRequest = { + author: 'jane', + pageNumber: 1, + }; + expect(blogPostKeys.list(filter)).toEqual(['blogPost', 'list', filter]); + }); + + it('byId(id) returns the by-id literal', () => { + expect(blogPostKeys.byId(id)).toEqual(['blogPost', 'byId', id]); + }); +}); diff --git a/Club12-WebClient/src/modules/blogPost/queryKeys.ts b/Club12-WebClient/src/modules/blogPost/queryKeys.ts new file mode 100644 index 0000000..aa6b58e --- /dev/null +++ b/Club12-WebClient/src/modules/blogPost/queryKeys.ts @@ -0,0 +1,9 @@ +import { GetBlogPostsFilteredRequest } from '@/modules/blogPost/type/blogPost'; + +export const blogPostKeys = { + list: (filter?: GetBlogPostsFilteredRequest) => + filter === undefined + ? (['blogPost', 'list'] as const) + : (['blogPost', 'list', filter] as const), + byId: (idOrSlug: string) => ['blogPost', 'byId', idOrSlug] as const, +}; diff --git a/Club12-WebClient/src/modules/blogPost/service/blogPost.service.ts b/Club12-WebClient/src/modules/blogPost/service/blogPost.service.ts new file mode 100644 index 0000000..37919eb --- /dev/null +++ b/Club12-WebClient/src/modules/blogPost/service/blogPost.service.ts @@ -0,0 +1,120 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + BlogPostResponse, + CreateBlogPostRequest, + GetBlogPostsFilteredRequest, + UpdateBlogPostRequest, +} from '@/modules/blogPost/type/blogPost'; + +/** + * BlogPostService provides methods to interact with the blog posts API. + */ +export const blogPostService = { + /** + * Adds a new blog post. + * @param {CreateBlogPostRequest} post - The post data to be added. + * @returns {Promise>} - A promise that resolves with the server response. + */ + addBlogPost: ( + post: CreateBlogPostRequest + ): Promise> => { + const formData = new FormData(); + formData.append('Author', post.author); + formData.append('Title', post.title); + formData.append('MarkdownText', post.markdownText); + + // HU-16: only override the server default (published) when the author + // explicitly chose a draft, keeping the create request backward-compatible. + if (post.isPublished !== undefined) { + formData.append('IsPublished', String(post.isPublished)); + } + + if (post.photoFile) { + formData.append('PhotoFile', post.photoFile as Blob); + } + + return sendPost(routes.blogposts, formData); + }, + + /** + * Updates an existing blog post by its ID. + * @param {string} id - The ID of the blog post to be updated. + * @param {UpdateBlogPostRequest} post - The updated post data. + * @returns {Promise>} - A promise that resolves with the server response. + */ + putBlogPostById: async ( + id: GUID, + post: UpdateBlogPostRequest + ): Promise> => { + const formData = new FormData(); + if (post.author) formData.append('Author', post.author); + + if (post.title) formData.append('Title', post.title); + + if (post.markdownText) formData.append('MarkdownText', post.markdownText); + + // HU-16: forward the publication state only when the caller set it, so an + // edit that does not touch the draft/published toggle leaves it unchanged. + if (post.isPublished !== undefined) { + formData.append('IsPublished', String(post.isPublished)); + } + + return sendPut(`${routes.blogposts}/${id}`, formData); + }, + + /** + * Updates the photo of an existing blog post by its ID. + * @param {string} id - The ID of the blog post. + * @param {File} photo - The new photo file to be uploaded. + * @returns {Promise>} - A promise that resolves with the server response. + */ + putPhotoBlogPostById: async ( + id: GUID, + photo: File + ): Promise> => { + const formData = new FormData(); + formData.append('PhotoFile', photo); + + return sendPut(`${routes.blogposts}/${id}/photo`, formData); + }, + + /** + * Gets a blog post by its ID or its public slug. + * @param {string} idOrSlug - The ID or slug of the blog post to retrieve. + * @returns {Promise>} - A promise that resolves with the blog post data. + */ + getBlogPostsById: async ( + idOrSlug: string + ): Promise> => + sendGet(`${routes.blogposts}/${idOrSlug}`), + + /** + * Fetches blog posts based on filters and pagination. + * @param filter The filter criteria to apply when fetching blog posts. + * @returns A promise that resolves with a paginated response containing filtered blog posts. + */ + getBlogPostsByFilters: async ( + filter: GetBlogPostsFilteredRequest + ): Promise>> => + sendGet>( + routes.blogposts, + withTablePageSize(filter) + ), + + /** + * Deletes a blog post by its ID. + * @param {string} id - The ID of the blog post to delete. + * @returns {Promise>} - A promise that resolves when the blog post is deleted. + */ + deleteBlogPostById: async (id: GUID): Promise> => + sendDelete(`${routes.blogposts}/${id}`), +}; diff --git a/Club12-WebClient/src/modules/blogPost/type/blogPost.d.ts b/Club12-WebClient/src/modules/blogPost/type/blogPost.d.ts new file mode 100644 index 0000000..159f593 --- /dev/null +++ b/Club12-WebClient/src/modules/blogPost/type/blogPost.d.ts @@ -0,0 +1,226 @@ +import { + FetchOptions, + Filtered, + GenericResponsePagination, +} from '@/modules/core/types/types'; + +/** + * Context properties and methods for managing blog posts in a React application. + * These methods interact with the backend for creating, updating, fetching, and deleting blog posts. + * @interface IBlogPostContextProps + */ +export interface IBlogPostContextProps { + /** + * Adds a new blog post. + * @param post The details of the blog post to add. + * @returns A promise that resolves with the response containing the newly added blog post. + */ + addBlogPost(post: CreateBlogPostRequest): Promise; + + /** + * Updates an existing blog post by its ID. + * @param id The ID of the blog post to update. + * @param post The updated blog post data. + * @returns A promise that resolves with the response containing the updated blog post. + */ + putBlogPostById( + id: GUID, + post: UpdateBlogPostRequest + ): Promise; + + /** + * Updates the photo of an existing blog post by its ID. + * @param id The ID of the blog post to update the photo for. + * @param photo The new photo file to upload. + * @returns A promise that resolves when the photo is successfully updated. + */ + putPhotoBlogPostById(id: GUID, photo: File): Promise; + + /** + * Fetches a blog post by its ID or its public slug. + * @param idOrSlug The ID or slug of the blog post to fetch. + * @returns A promise that resolves with the blog post data. + */ + getBlogPostsById( + idOrSlug: string, + options?: FetchOptions + ): Promise; + + /** + * Fetches blog posts based on filters and pagination. + * @param filter The filter criteria to apply when fetching blog posts. + * @param options Per-call options; `silent` suppresses the global alert on failure. + * @returns A promise that resolves with a paginated response containing filtered blog posts. + */ + getBlogPostsByFilters( + filter: GetBlogPostsFilteredRequest, + options?: FetchOptions + ): Promise | void>; + + /** + * Deletes a blog post by its ID. + * @param id The ID of the blog post to delete. + * @returns A promise resolving to `true` if the blog post was deleted, + * `false` if the request failed (the global error is already reported + * either way). + */ + deleteBlogPostById(id: GUID): Promise; +} + +/** + * The request body structure for adding a new blog post. + * @interface CreateBlogPostRequest + */ +export interface CreateBlogPostRequest { + /** + * The author of the blog post. + * @type {string} + */ + author: string; + + /** + * The title of the blog post. + * @type {string} + */ + title: string; + + /** + * The photo file to upload for the blog post (optional). + * @type {File} + */ + photoFile?: File; + + /** + * The markdown text content of the blog post. + * @type {string} + */ + markdownText: string; + + /** + * Whether the post is published (visible publicly) or saved as a draft + * (HU-16). Defaults to published when omitted. + * @type {boolean} + */ + isPublished?: boolean; +} + +/** + * The request body structure for updating an existing blog post. + * @interface UpdateBlogPostRequest + */ +export interface UpdateBlogPostRequest { + /** + * The updated title of the blog post. + * @type {string} + */ + title?: string; + + /** + * The updated markdown text content of the blog post. + * @type {string} + */ + markdownText?: string; + + /** + * The updated author of the blog post. + * @type {string} + */ + author?: string; + + /** + * The updated publication state (HU-16). `undefined` leaves the current + * state untouched; `true` publishes, `false` turns it back into a draft. + * @type {boolean} + */ + isPublished?: boolean; +} + +/** + * The request body structure for updating the photo of an existing blog post. + * @interface UpdateBlogPostPhotoRequest + */ +export interface UpdateBlogPostPhotoRequest { + /** + * The file of the blog post photo to be updated. + * @type {File} + */ + photoFile: File; +} + +/** + * The response structure for a blog post, including its details and metadata. + * @interface BlogPostResponse + */ +export interface BlogPostResponse { + /** + * The unique identifier of the blog post. + * @type {string} + */ + id: GUID; + + /** + * The author of the blog post. + * @type {string} + */ + author: string; + + /** + * The title of the blog post. + * @type {string} + */ + title: string; + + /** + * The unique, URL-friendly identifier used in public blog post links. + * @type {string} + */ + slug: string; + + /** + * The number of views the blog post has received. + * @type {number} + */ + views: number; + + /** + * The URL of the photo associated with the blog post. + * @type {string} + */ + photoUrl?: string; + + /** + * The markdown text content of the blog post. + * @type {string} + */ + markdownText: string; + + /** + * The date and time the blog post was created. + * @type {Date} + */ + createdAt: Date; + + /** + * Whether the post is published (visible publicly) or a draft (HU-16). + * @type {boolean} + */ + isPublished: boolean; +} + +/** + * The request body structure for fetching filtered blog posts with pagination. + * @interface GetBlogPostsFilteredRequest + */ +export interface GetBlogPostsFilteredRequest extends Filtered { + /** + * The author to filter blog posts by. + * @type {string} + */ + author?: string; + + /** + * The title to filter blog posts by. + * @type {string} + */ + title?: string; +} diff --git a/Club12-WebClient/src/modules/champion/service/champion.service.ts b/Club12-WebClient/src/modules/champion/service/champion.service.ts new file mode 100644 index 0000000..223936c --- /dev/null +++ b/Club12-WebClient/src/modules/champion/service/champion.service.ts @@ -0,0 +1,36 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { GUID } from '@/modules/core/types/types'; +import { sendGet } from '@/modules/core/utils/axiosUtils'; +import { + IChampionHistory, + IPodium, +} from '@/modules/champion/type/champion.d'; + +/** + * Service for public champions/podium data (read-only). + */ +export const championService = { + /** + * Fetches the podium (top three per division) of a tournament by its id or + * public slug. Returns one entry per division; a place is `null` until it is + * decided. + * @param {string} idOrSlug - Tournament id or slug. + * @returns {Promise>} The server response. + */ + getTournamentChampions: async ( + idOrSlug: string + ): Promise> => + sendGet(`${routes.tournaments}/${idOrSlug}/champions`), + + /** + * Fetches the public champions history (finished tournaments only), + * optionally scoped to a single season. + * @param {GUID} [seasonId] - Optional season to filter by. + * @returns {Promise>} The server response. + */ + getChampionsHistory: async ( + seasonId?: GUID + ): Promise> => + sendGet(routes.champions, seasonId ? { seasonId } : undefined), +}; diff --git a/Club12-WebClient/src/modules/champion/type/champion.d.ts b/Club12-WebClient/src/modules/champion/type/champion.d.ts new file mode 100644 index 0000000..881aeb7 --- /dev/null +++ b/Club12-WebClient/src/modules/champion/type/champion.d.ts @@ -0,0 +1,52 @@ +import { GUID } from '@/modules/core/types/types'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; + +/** + * A single team occupying a podium place. Mirrors the backend's minimal team + * projection (id + display name + optional logo) used for champions/podium. + */ +export interface IPodiumTeam { + teamId: GUID; + teamName: string; + logoUrl: string | null; +} + +/** + * The top-three finish of a single division (HU-Champions). `first`/`second`/ + * `third` are `null` until that place is decided. `hasPlayoff` distinguishes a + * podium crowned by a playoff bracket from one read straight off the final + * standings — both are valid top-threes. + */ +export interface IPodium { + divisionId: GUID; + divisionName: string; + hasPlayoff: boolean; + first: IPodiumTeam | null; + second: IPodiumTeam | null; + third: IPodiumTeam | null; +} + +/** + * A single champion entry in the public history (only finished tournaments). + * `category` is the raw backend enum name ("Masculine"/"Feminine") — display it + * through `TOURNAMENT_CATEGORY_LABELS`. + */ +export interface IChampionHistory { + tournamentId: GUID; + tournamentName: string; + seasonName: string | null; + /** + * The calendar year of the season, or null when there is no season or it + * has no year. The public page sorts seasons by this value, newest first. + */ + seasonYear: number | null; + category: TournamentCategory; + divisionName: string; + /** + * The sub-cup (playoff bracket) that was won, e.g. "Copa Oro" / "Copa Plata", + * when the division splits its playoff into tiers. Null when the division + * crowns a single champion (a single bracket or a group-only division). + */ + cupName: string | null; + championTeam: IPodiumTeam; +} diff --git a/Club12-WebClient/src/modules/champion/utils/groupChampions.test.ts b/Club12-WebClient/src/modules/champion/utils/groupChampions.test.ts new file mode 100644 index 0000000..d303448 --- /dev/null +++ b/Club12-WebClient/src/modules/champion/utils/groupChampions.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { IChampionHistory } from '@/modules/champion/type/champion.d'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; +import { groupChampions } from './groupChampions'; + +const guid = (value: string) => value as GUID; + +const entry = (overrides: Partial = {}): IChampionHistory => ({ + tournamentId: guid('tournament-1'), + tournamentName: 'Apertura 2025', + seasonName: 'Temporada 2025', + seasonYear: 2025, + category: TournamentCategory.Masculine, + divisionName: 'Zona A', + cupName: null, + championTeam: { + teamId: guid('team-1'), + teamName: 'Los Halcones', + logoUrl: null, + }, + ...overrides, +}); + +describe('groupChampions', () => { + it('returns an empty array for empty history', () => { + expect(groupChampions([])).toEqual([]); + }); + + it('orders seasons by year, newest first', () => { + const result = groupChampions([ + entry({ seasonName: 'Temporada 2025', seasonYear: 2025 }), + entry({ seasonName: 'Temporada 2026', seasonYear: 2026 }), + entry({ seasonName: 'Temporada 2024', seasonYear: 2024 }), + ]); + + expect(result.map(season => season.seasonName)).toEqual([ + 'Temporada 2026', + 'Temporada 2025', + 'Temporada 2024', + ]); + expect(result.map(season => season.seasonYear)).toEqual([2026, 2025, 2024]); + }); + + it('sorts null-year seasons (including "Sin temporada") last', () => { + const result = groupChampions([ + entry({ seasonName: null, seasonYear: null }), + entry({ seasonName: 'Temporada 2025', seasonYear: 2025 }), + ]); + + expect(result.map(season => season.seasonName)).toEqual([ + 'Temporada 2025', + 'Sin temporada', + ]); + }); + + it('buckets null or empty seasons under "Sin temporada"', () => { + const result = groupChampions([ + entry({ seasonName: null, seasonYear: null }), + entry({ seasonName: '', seasonYear: null }), + ]); + + expect(result).toHaveLength(1); + expect(result[0].seasonName).toBe('Sin temporada'); + }); + + it('nests Season -> Tournament -> Division, carrying the category on the tournament', () => { + const result = groupChampions([ + entry({ + tournamentId: guid('t-masc'), + tournamentName: 'Apertura Masculino', + category: TournamentCategory.Masculine, + divisionName: 'Zona A', + }), + entry({ + tournamentId: guid('t-fem'), + tournamentName: 'Apertura Femenino', + category: TournamentCategory.Feminine, + divisionName: 'Zona Única', + }), + entry({ + tournamentId: guid('t-masc'), + tournamentName: 'Apertura Masculino', + category: TournamentCategory.Masculine, + divisionName: 'Zona B', + }), + ]); + + expect(result).toHaveLength(1); + const [season] = result; + expect(season.tournaments.map(t => t.tournamentName)).toEqual([ + 'Apertura Masculino', + 'Apertura Femenino', + ]); + + const masc = season.tournaments.find(t => t.tournamentId === guid('t-masc')); + expect(masc?.category).toBe(TournamentCategory.Masculine); + expect(masc?.divisions.map(d => d.divisionName)).toEqual(['Zona A', 'Zona B']); + }); + + it('keeps every sub-cup champion of a division, in backend (tier) order', () => { + const result = groupChampions([ + entry({ divisionName: 'Primera', cupName: 'Copa Oro', championTeam: { + teamId: guid('gold'), teamName: 'Oro FC', logoUrl: null, + } }), + entry({ divisionName: 'Primera', cupName: 'Copa Plata', championTeam: { + teamId: guid('silver'), teamName: 'Plata FC', logoUrl: null, + } }), + ]); + + const division = result[0].tournaments[0].divisions[0]; + expect(division.divisionName).toBe('Primera'); + expect(division.entries.map(e => e.cupName)).toEqual(['Copa Oro', 'Copa Plata']); + }); +}); diff --git a/Club12-WebClient/src/modules/champion/utils/groupChampions.ts b/Club12-WebClient/src/modules/champion/utils/groupChampions.ts new file mode 100644 index 0000000..4a23611 --- /dev/null +++ b/Club12-WebClient/src/modules/champion/utils/groupChampions.ts @@ -0,0 +1,99 @@ +import { IChampionHistory } from '@/modules/champion/type/champion.d'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; +import { GUID } from '@/modules/core/types/types'; + +/** A division bucket inside a tournament, holding its per-cup champions in order. */ +export interface ChampionDivisionGroup { + divisionName: string; + entries: IChampionHistory[]; +} + +/** A tournament bucket inside a season, holding its divisions. Carries the + * tournament's category so the view can badge it. */ +export interface ChampionTournamentGroup { + tournamentId: GUID; + tournamentName: string; + category: TournamentCategory; + divisions: ChampionDivisionGroup[]; +} + +/** A season bucket holding its tournaments. */ +export interface ChampionSeasonGroup { + seasonName: string; + /** Calendar year of the season; null for "Sin temporada" or a year-less season. */ + seasonYear: number | null; + tournaments: ChampionTournamentGroup[]; +} + +/** Fallback label for tournaments not yet assigned to a season. */ +const NO_SEASON_LABEL = 'Sin temporada'; + +/** + * Shapes the flat champion history into the public page's hierarchy: + * Season → Tournament → Division → per-cup champion entries. Seasons are + * ordered by year, newest first; a year-less season (including the single + * "Sin temporada" bucket for entries with a null/empty season) sorts last, + * with `seasonName` descending as the deterministic tiebreak. Within a season + * the tournament, division and per-cup order follows the backend's + * first-appearance order, so the within-division entries keep the backend's + * tier order (Copa Oro before Copa Plata). A tournament already implies its + * category, so category is carried on the tournament rather than used as a + * grouping level. + */ +export const groupChampions = ( + history: IChampionHistory[] +): ChampionSeasonGroup[] => { + const seasonOrder: string[] = []; + const bySeason = new Map(); + + history.forEach(entry => { + const seasonKey = entry.seasonName || NO_SEASON_LABEL; + + let season = bySeason.get(seasonKey); + if (!season) { + season = { + seasonName: seasonKey, + seasonYear: entry.seasonName ? entry.seasonYear : null, + tournaments: [], + }; + bySeason.set(seasonKey, season); + seasonOrder.push(seasonKey); + } + + let tournament = season.tournaments.find( + t => t.tournamentId === entry.tournamentId + ); + if (!tournament) { + tournament = { + tournamentId: entry.tournamentId, + tournamentName: entry.tournamentName, + category: entry.category, + divisions: [], + }; + season.tournaments.push(tournament); + } + + let division = tournament.divisions.find( + d => d.divisionName === entry.divisionName + ); + if (!division) { + division = { divisionName: entry.divisionName, entries: [] }; + tournament.divisions.push(division); + } + + division.entries.push(entry); + }); + + return seasonOrder + .map(seasonName => bySeason.get(seasonName)!) + .sort((a, b) => { + // Year desc; a null year always sorts after a real one. + if (a.seasonYear !== b.seasonYear) { + if (a.seasonYear === null) return 1; + if (b.seasonYear === null) return -1; + return b.seasonYear - a.seasonYear; + } + // Same year (or both null): name desc, for a deterministic order. + return b.seasonName.localeCompare(a.seasonName); + }); +}; diff --git a/Club12-WebClient/src/modules/club/context/club.context.tsx b/Club12-WebClient/src/modules/club/context/club.context.tsx new file mode 100644 index 0000000..9fb8946 --- /dev/null +++ b/Club12-WebClient/src/modules/club/context/club.context.tsx @@ -0,0 +1,219 @@ +import { AxiosResponse } from 'axios'; +import { + createContext, + ReactNode, + useCallback, + useMemo, + useState, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { GUID } from '@/modules/core/types/types'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { clubService } from '@/modules/club/service/club.service'; +import { clubKeys } from '@/modules/club/queryKeys'; +import { + IClubContextProps, + IClubHistoryResponse, + IClubSummaryResponse, + IRosterCopyRequest, + IRosterCopyResult, +} from '@/modules/club/type/club.d'; + +export const ClubContext = createContext( + undefined +); + +export const ClubProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [club, setClub] = useState(null); + const [allClubs, setAllClubs] = useState([]); + + const queryClient = useQueryClient(); + const handleUnknownError = useUnknownErrorHandler(); + + const copyRosterMutation = useMutation({ + mutationFn: ({ + targetTeamId, + request, + }: { + targetTeamId: GUID; + request: IRosterCopyRequest; + }) => clubService.copyRoster(targetTeamId, request), + }); + + const linkClubParentMutation = useMutation({ + mutationFn: ({ + childClubId, + parentClubId, + }: { + childClubId: GUID; + parentClubId: GUID; + }) => clubService.linkClubParent(childClubId, parentClubId), + }); + + const unlinkClubParentMutation = useMutation({ + mutationFn: (childClubId: GUID) => clubService.unlinkClubParent(childClubId), + }); + + const renameClubMutation = useMutation({ + mutationFn: ({ clubId, name }: { clubId: GUID; name: string }) => + clubService.renameClub(clubId, name), + }); + + const deleteClubMutation = useMutation({ + mutationFn: (clubId: GUID) => clubService.deleteClub(clubId), + }); + + const getClubHistory = useCallback( + async (idOrSlug: string): Promise => { + try { + const res: AxiosResponse = + await queryClient.fetchQuery({ + queryKey: clubKeys.history(idOrSlug), + queryFn: async () => await clubService.getClubHistory(idOrSlug), + }); + + if (res) { + setClub(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const copyRoster = useCallback( + async ( + targetTeamId: GUID, + request: IRosterCopyRequest + ): Promise => { + try { + const res: AxiosResponse = + await copyRosterMutation.mutateAsync({ targetTeamId, request }); + return res?.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [copyRosterMutation, handleUnknownError] + ); + + const getAllClubs = useCallback(async (): Promise< + IClubSummaryResponse[] | void + > => { + try { + const res: AxiosResponse = + await queryClient.fetchQuery({ + queryKey: clubKeys.all(), + queryFn: async () => await clubService.getAllClubs(), + }); + + if (res) { + setAllClubs(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, [queryClient, handleUnknownError]); + + const linkClubParent = useCallback( + async ( + childClubId: GUID, + parentClubId: GUID + ): Promise => { + try { + const res: AxiosResponse = + await linkClubParentMutation.mutateAsync({ + childClubId, + parentClubId, + }); + if (res) { + setClub(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [linkClubParentMutation, handleUnknownError] + ); + + const unlinkClubParent = useCallback( + async (childClubId: GUID): Promise => { + try { + const res: AxiosResponse = + await unlinkClubParentMutation.mutateAsync(childClubId); + if (res) { + setClub(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [unlinkClubParentMutation, handleUnknownError] + ); + + const renameClub = useCallback( + async (clubId: GUID, name: string): Promise => { + try { + const res: AxiosResponse = + await renameClubMutation.mutateAsync({ clubId, name }); + if (res) { + setClub(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [renameClubMutation, handleUnknownError] + ); + + const deleteClub = useCallback( + async (clubId: GUID): Promise => { + try { + await deleteClubMutation.mutateAsync(clubId); + setAllClubs(prev => prev.filter(candidate => candidate.id !== clubId)); + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [deleteClubMutation, handleUnknownError] + ); + + const container: IClubContextProps = useMemo( + () => ({ + club, + getClubHistory, + copyRoster, + allClubs, + getAllClubs, + linkClubParent, + unlinkClubParent, + renameClub, + deleteClub, + }), + [ + club, + getClubHistory, + copyRoster, + allClubs, + getAllClubs, + linkClubParent, + unlinkClubParent, + renameClub, + deleteClub, + ] + ); + + return ( + {children} + ); +}; diff --git a/Club12-WebClient/src/modules/club/hook/club.hook.ts b/Club12-WebClient/src/modules/club/hook/club.hook.ts new file mode 100644 index 0000000..e0270f0 --- /dev/null +++ b/Club12-WebClient/src/modules/club/hook/club.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ClubContext } from '@/modules/club/context/club.context'; + +export const useClub = () => { + const context = useContext(ClubContext); + if (!context) { + throw new Error('useClub must be used within a ClubProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/club/queryKeys.ts b/Club12-WebClient/src/modules/club/queryKeys.ts new file mode 100644 index 0000000..14d0763 --- /dev/null +++ b/Club12-WebClient/src/modules/club/queryKeys.ts @@ -0,0 +1,4 @@ +export const clubKeys = { + history: (idOrSlug: string) => ['club', 'history', idOrSlug] as const, + all: () => ['club', 'all'] as const, +}; diff --git a/Club12-WebClient/src/modules/club/service/club.service.ts b/Club12-WebClient/src/modules/club/service/club.service.ts new file mode 100644 index 0000000..a19088e --- /dev/null +++ b/Club12-WebClient/src/modules/club/service/club.service.ts @@ -0,0 +1,87 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { GUID } from '@/modules/core/types/types'; +import { sendDelete, sendGet, sendPost, sendPut } from '@/modules/core/utils/axiosUtils'; +import { + IClubHistoryResponse, + IClubSummaryResponse, + IRosterCopyRequest, + IRosterCopyResult, +} from '@/modules/club/type/club.d'; + +/** + * Service for the stable cross-season club identity (HU-99) and roster + * cloning between seasons (HU-53). + */ +export const clubService = { + /** + * Retrieves a club and its trajectory across seasons. + * @param {string} idOrSlug - The club's GUID id or its slug. + * @returns {Promise>} The server response. + */ + getClubHistory: async ( + idOrSlug: string + ): Promise> => + await sendGet(`${routes.clubs}/${idOrSlug}`), + + /** + * Clones a roster from a previous season's team into the target team. + * @param {GUID} targetTeamId - The team to copy the roster into. + * @param {IRosterCopyRequest} request - The source team + season and target season. + * @returns {Promise>} The server response. + */ + copyRoster: async ( + targetTeamId: GUID, + request: IRosterCopyRequest + ): Promise> => + await sendPost(`${routes.teams}/${targetTeamId}/roster/copy`, request), + + /** + * Retrieves every club's stable identity summary. + * @returns {Promise>} The server response. + */ + getAllClubs: async (): Promise> => + await sendGet(routes.clubs), + + /** + * Links a club as a squad of a parent institution club. + * @param {GUID} childClubId - The squad club to link. + * @param {GUID} parentClubId - The institution club it becomes a squad of. + * @returns {Promise>} The server response. + */ + linkClubParent: async ( + childClubId: GUID, + parentClubId: GUID + ): Promise> => + await sendPut(`${routes.clubs}/${childClubId}/parent`, { parentClubId }), + + /** + * Clears a club's parent institution link, if any. + * @param {GUID} childClubId - The club to unlink. + * @returns {Promise>} The server response. + */ + unlinkClubParent: async ( + childClubId: GUID + ): Promise> => + await sendDelete(`${routes.clubs}/${childClubId}/parent`), + + /** + * Renames a club. + * @param {GUID} clubId - The club to rename. + * @param {string} name - The new display name. + * @returns {Promise>} The server response. + */ + renameClub: async ( + clubId: GUID, + name: string + ): Promise> => + await sendPut(`${routes.clubs}/${clubId}`, { name }), + + /** + * Deletes a club. + * @param {GUID} clubId - The club to delete. + * @returns {Promise>} The server response. + */ + deleteClub: async (clubId: GUID): Promise> => + await sendDelete(`${routes.clubs}/${clubId}`), +}; diff --git a/Club12-WebClient/src/modules/club/type/club.d.ts b/Club12-WebClient/src/modules/club/type/club.d.ts new file mode 100644 index 0000000..b493e11 --- /dev/null +++ b/Club12-WebClient/src/modules/club/type/club.d.ts @@ -0,0 +1,162 @@ +import { GUID } from '@/modules/core/types/types'; + +/** + * One season (tournament) a club's team was registered in. + * @interface IClubSeasonResponse + */ +export interface IClubSeasonResponse { + /** The tournament (season) id. */ + tournamentId: GUID; + /** The tournament (season) display name, when available. */ + tournamentName: string | null; + /** + * The tournament's start date (ISO string). Sort key only — the history + * table shows the tournament name, and rows are ordered newest-first by + * this value. + */ + startDate: string; +} + +/** + * One per-season team belonging to a club, with the seasons it played. + * @interface IClubTeamSeasonResponse + */ +export interface IClubTeamSeasonResponse { + /** The per-season team id. */ + teamId: GUID; + /** The team name for that season. */ + name: string; + /** The team's URL-friendly slug. */ + slug: string; + /** The three-letter code of the team. */ + threeLetterCode: string; + /** The tournaments (seasons) this team was registered in. */ + seasons: IClubSeasonResponse[]; +} + +/** + * A minimal club identity, used for pickers and for referencing a related + * club (parent institution / child squad) without its full season history. + * @interface IClubSummaryResponse + */ +export interface IClubSummaryResponse { + id: GUID; + name: string; + slug: string; + logoUrl: string | null; +} + +/** + * A club and its trajectory across seasons (HU-99): the stable club identity + * plus every per-season team that belongs to it. + * @interface IClubHistoryResponse + */ +export interface IClubHistoryResponse { + /** The stable club id. */ + id: GUID; + /** The club name. */ + name: string; + /** The club's URL-friendly slug. */ + slug: string; + /** The club logo URL, when available. */ + logoUrl: string | null; + /** The per-season teams that make up this club's history. */ + teams: IClubTeamSeasonResponse[]; + /** + * The parent institution this club is a squad of, or null when this club + * has no parent linked. + */ + parentClub: IClubSummaryResponse | null; + /** Other squads linked to this club as their parent institution. */ + childClubs: IClubSummaryResponse[]; +} + +/** + * Request body to clone a roster from a previous season's team (HU-53). The + * target team is taken from the route; this identifies the source team + + * season to copy from and the target season to copy into. + * @interface IRosterCopyRequest + */ +export interface IRosterCopyRequest { + /** The past-season team whose roster is the source. */ + sourceTeamId: GUID; + /** The season (tournament) the source roster belongs to. */ + sourceTournamentId: GUID; + /** The new season (tournament) the roster is cloned into. */ + targetTournamentId: GUID; +} + +/** + * Outcome of copying a roster into a new season (HU-53). + * @interface IRosterCopyResult + */ +export interface IRosterCopyResult { + /** New season registrations created on the target team. */ + copiedCount: number; + /** Source players skipped because already registered to the target season. */ + skippedCount: number; +} + +/** + * Context properties for reading a club's cross-season history (HU-99) and + * importing a roster from a previous season (HU-53). + * @interface IClubContextProps + */ +export interface IClubContextProps { + /** The last-fetched club history, or null. */ + club: IClubHistoryResponse | null; + + /** + * Fetches a club and its per-season trajectory by id or slug. + * @param idOrSlug The club's GUID id or its slug. + * @returns A promise that resolves with the club history. + */ + getClubHistory(idOrSlug: string): Promise; + + /** + * Clones a roster from a previous season's team into a target team. + * @param targetTeamId The team to copy the roster into. + * @param request The source team + season and the target season. + * @returns A promise that resolves with the copied/skipped counts. + */ + copyRoster( + targetTeamId: GUID, + request: IRosterCopyRequest + ): Promise; + + /** Every club's stable identity summary, for the "link to parent club" picker. */ + allClubs: IClubSummaryResponse[]; + + /** Fetches every club's stable identity summary. */ + getAllClubs(): Promise; + + /** + * Links a club as a squad of a parent institution club. + * @param childClubId The squad club to link. + * @param parentClubId The institution club it becomes a squad of. + */ + linkClubParent( + childClubId: GUID, + parentClubId: GUID + ): Promise; + + /** + * Clears a club's parent institution link, if any. + * @param childClubId The club to unlink. + */ + unlinkClubParent(childClubId: GUID): Promise; + + /** + * Renames a club. The club's slug never changes, so its public URL stays + * stable. + * @param clubId The club to rename. + * @param name The new display name. + */ + renameClub(clubId: GUID, name: string): Promise; + + /** + * Deletes a club, rejected while it still has teams or squad clubs linked to it. + * @param clubId The club to delete. + */ + deleteClub(clubId: GUID): Promise; +} diff --git a/Club12-WebClient/src/modules/core/constants/appRoutes.ts b/Club12-WebClient/src/modules/core/constants/appRoutes.ts new file mode 100644 index 0000000..823caac --- /dev/null +++ b/Club12-WebClient/src/modules/core/constants/appRoutes.ts @@ -0,0 +1,124 @@ +/** + * Single source of truth for every frontend page path. Static paths are + * plain strings (used directly in Route definitions, nav links and + * navigate() calls). Dynamic paths expose both a `pattern` (with the + * :param placeholder, for Route definitions) and a `build` function (for + * navigate() calls with a real id). + */ +export const APP_ROUTES = { + home: '/', + login: '/login', + forgotPassword: '/auth/olvide-password', + activate: '/auth/activar', + passwordReset: '/auth/password-reset', + quienesSomos: '/quienes-somos', + fichaMedica: '/ficha-medica', + reglamento: '/reglamento', + forbidden: '/forbidden', + + publicTeam: { + pattern: '/equipos/:teamId', + build: (teamId: string) => `/equipos/${teamId}`, + }, + publicSanctions: '/sanciones', + publicChampions: '/campeones', + publicMatch: { + pattern: '/partidos/:matchId', + build: (matchId: string) => `/partidos/${matchId}`, + }, + publicSeasons: '/temporadas', + publicSeason: { + pattern: '/temporadas/:seasonId', + build: (seasonId: string) => `/temporadas/${seasonId}`, + }, + publicTournament: { + pattern: '/torneos/:tournamentId', + build: (tournamentId: string) => `/torneos/${tournamentId}`, + }, + publicBlog: '/blog', + blogPost: { + pattern: '/blog/:idOrSlug', + build: (idOrSlug: string) => `/blog/${idOrSlug}`, + }, + + panel: '/panel', + panelPlayers: '/panel/jugadores', + panelPlayer: { + pattern: '/panel/jugadores/:playerId', + build: (playerId: string) => `/panel/jugadores/${playerId}`, + }, + panelTeamDetail: { + pattern: '/panel/equipos/:teamId', + build: (teamId: string) => `/panel/equipos/${teamId}`, + }, + panelTournamentDetail: { + pattern: '/panel/torneos/:tournamentId', + build: (tournamentId: string) => `/panel/torneos/${tournamentId}`, + }, + panelTournamentEdit: { + pattern: '/panel/torneos/:tournamentId/editar', + build: (tournamentId: string) => `/panel/torneos/${tournamentId}/editar`, + }, + panelTeams: '/panel/equipos', + panelClub: { + pattern: '/panel/clubes/:idOrSlug', + build: (idOrSlug: string) => `/panel/clubes/${idOrSlug}`, + }, + panelSanctions: '/panel/sanciones', + panelSanction: { + pattern: '/panel/sanciones/:playerSanctionId', + build: (playerSanctionId: string) => `/panel/sanciones/${playerSanctionId}`, + }, + panelSanctionEdit: { + pattern: '/panel/sanciones/editar/:playerSanctionId', + build: (playerSanctionId: string) => + `/panel/sanciones/editar/${playerSanctionId}`, + }, + panelVenues: '/panel/canchas', + panelVenue: { + pattern: '/panel/canchas/:venueId', + build: (venueId: string) => `/panel/canchas/${venueId}`, + }, + panelSeasons: '/panel/temporadas', + panelSeason: { + pattern: '/panel/temporadas/:seasonId', + build: (seasonId: string) => `/panel/temporadas/${seasonId}`, + }, + panelTournamentWizard: '/panel/torneos/asistente', + panelDivisionCreate: '/panel/divisiones/crear', + panelDivisionEdit: { + pattern: '/panel/divisiones/:divisionId/editar', + build: (divisionId: string) => `/panel/divisiones/${divisionId}/editar`, + }, + panelDivision: { + pattern: '/panel/divisiones/:divisionId', + build: (divisionId: string) => `/panel/divisiones/${divisionId}`, + }, + panelMatch: { + pattern: '/panel/partidos/:matchId', + build: (matchId: string) => `/panel/partidos/${matchId}`, + }, + panelBlog: '/panel/blog', + panelBlogCreate: '/panel/blog/crear', + panelBlogEdit: { + pattern: '/panel/blog/:blogPostId/editar', + build: (blogPostId: string) => `/panel/blog/${blogPostId}/editar`, + }, + panelUsers: '/panel/usuarios', + panelUserCreate: '/panel/usuarios/crear', + panelUserInvite: '/panel/usuarios/invitar', + panelUserEdit: { + pattern: '/panel/usuarios/:userId/editar', + build: (userId: string) => `/panel/usuarios/${userId}/editar`, + }, + panelUser: { + pattern: '/panel/usuarios/:userId', + build: (userId: string) => `/panel/usuarios/${userId}`, + }, + panelSettings: '/panel/configuracion', + panelChangePassword: '/panel/configuracion/cambiar-password', + panelEditProfile: '/panel/configuracion/editar-perfil', + panelStatistics: '/panel/estadisticas', + panelAuditLogs: '/panel/auditoria', + panelDataAdministration: '/panel/administracion-datos', +} as const; diff --git a/Club12-WebClient/src/modules/core/constants/constants.ts b/Club12-WebClient/src/modules/core/constants/constants.ts new file mode 100644 index 0000000..fe914d3 --- /dev/null +++ b/Club12-WebClient/src/modules/core/constants/constants.ts @@ -0,0 +1,42 @@ +export const COOKIE_SIGNIN_TOKEN = 'Club12_SignInToken'; + +export const SUCCESS_MESSAGES = { + LOGIN_SUCCESS: 'Sesión iniciada correctamente', +}; + +export const ERROR_MESSAGES = { + GENERIC_ERROR: 'Ocurrió un error. Por favor, intentá nuevamente.', + NETWORK_ERROR: + 'No se pudo conectar con el servidor. Verificá tu conexión e intentá nuevamente.', + SERVER_UNAVAILABLE: + 'El servidor no está disponible en este momento. Por favor, intentá nuevamente en unos minutos.', + LOGIN_FAILED: 'Usuario o contraseña incorrectos', +}; + +export const EXPIRATION_TIME = { + MS_IN_HOUR: 3600 * 1000, + MS_IN_MINUTE: 60 * 1000, + MS_IN_SECOND: 1000, +}; + +export const JWT = { + ACCESS_TOKEN: 'accessToken', + REFRESH_TOKEN: 'refreshToken', + EXPIRES_IN: 'expiresIn', +}; + +export const FILTERS_DEBOUNCE_DELAY_MS = 500; +export const FILTERS_DEBOUNCE_DELAY_LONG_MS = 1000; +export const PUBLIC_SEARCH_DEBOUNCE_DELAY_MS = 600; + +/** + * Applied to tabbed content areas (tournament/division tabs) so switching + * between a short tab (e.g. Información) and a long one (e.g. Partidos) + * doesn't visibly jump the page height and the footer position with it. + */ +export const TAB_CONTENT_MIN_HEIGHT = 400; + +export const USERNAME_LENGTH = { + Min: 3, + Max: 50, +} as const; diff --git a/Club12-WebClient/src/modules/core/constants/dataGridLocale.ts b/Club12-WebClient/src/modules/core/constants/dataGridLocale.ts new file mode 100644 index 0000000..a4ba91f --- /dev/null +++ b/Club12-WebClient/src/modules/core/constants/dataGridLocale.ts @@ -0,0 +1,27 @@ +import { esES } from '@mui/x-data-grid/locales'; + +/** + * Spanish (es-ES) localeText for the MUI X DataGrid — pager ("Filas por + * página", "de"), filters, column menu, etc. + * + * The theme already merges `esES` into + * `theme.components.MuiDataGrid.defaultProps.localeText`, which covers grids + * that do NOT pass their own `localeText`. But MUI's `resolveProps` only fills + * a prop from `defaultProps` when the component leaves it `undefined`: a page + * that passes `localeText={{ noRowsLabel }}` shallow-REPLACES the theme's + * Spanish localeText, so the footer falls back to the English defaults. + * + * Any DataGrid that needs a custom empty-rows message must therefore spread + * this constant into its `localeText` prop. `dataGridLocaleText(noRowsLabel)` + * does exactly that in a single call. + */ +export const DATA_GRID_ES_LOCALE_TEXT = + esES.components.MuiDataGrid.defaultProps.localeText; + +/** Spanish DataGrid localeText merged with a page-specific empty-rows label. */ +export const dataGridLocaleText = ( + noRowsLabel: string +): typeof DATA_GRID_ES_LOCALE_TEXT => ({ + ...DATA_GRID_ES_LOCALE_TEXT, + noRowsLabel, +}); diff --git a/Club12-WebClient/src/modules/core/constants/httpStatus.ts b/Club12-WebClient/src/modules/core/constants/httpStatus.ts new file mode 100644 index 0000000..e411d76 --- /dev/null +++ b/Club12-WebClient/src/modules/core/constants/httpStatus.ts @@ -0,0 +1,18 @@ +/** + * Named HTTP status codes used to interpret API responses, instead of + * comparing against raw numeric literals throughout the app. + */ +export const HttpStatus = { + Ok: 200, + Created: 201, + NoContent: 204, + BadRequest: 400, + Unauthorized: 401, + Forbidden: 403, + NotFound: 404, + Conflict: 409, + InternalServerError: 500, + BadGateway: 502, + ServiceUnavailable: 503, + GatewayTimeout: 504, +} as const; diff --git a/Club12-WebClient/src/modules/core/constants/order.ts b/Club12-WebClient/src/modules/core/constants/order.ts new file mode 100644 index 0000000..492d966 --- /dev/null +++ b/Club12-WebClient/src/modules/core/constants/order.ts @@ -0,0 +1,4 @@ +export enum Order { + ASC = 0, + DESC = 1, +} diff --git a/Club12-WebClient/src/modules/core/constants/pagination.ts b/Club12-WebClient/src/modules/core/constants/pagination.ts new file mode 100644 index 0000000..3170ac7 --- /dev/null +++ b/Club12-WebClient/src/modules/core/constants/pagination.ts @@ -0,0 +1,23 @@ +import { Filtered } from '@/modules/core/types/types'; + +export const TABLE_ROWS_PER_PAGE = 10; +export const TABLE_PAGE_SIZE_OPTIONS = [10, 25, 50] as const; + +/** + * Page size used when fetching the full list of tournaments/divisions to + * populate a filter dropdown, effectively treating the fetch as "get all". + */ +export const FILTER_OPTIONS_PAGE_SIZE = 300; + +/** + * Page size used on public (unauthenticated) listing pages that fetch + * effectively all items in a single request. + */ +export const PUBLIC_LISTING_PAGE_SIZE = 100; + +export const withTablePageSize = ( + filter: T +): T & { pageSize: number } => ({ + ...filter, + pageSize: filter.pageSize ?? TABLE_ROWS_PER_PAGE, +}); diff --git a/Club12-WebClient/src/modules/core/constants/routes.test.ts b/Club12-WebClient/src/modules/core/constants/routes.test.ts new file mode 100644 index 0000000..9d58e77 --- /dev/null +++ b/Club12-WebClient/src/modules/core/constants/routes.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import routes from './routes'; + +describe('routes', () => { + it('tokenInvalido resolves to the invalid-token redirect path', () => { + expect(routes.tokenInvalido).toBe('/token-invalido'); + }); + + it('apiUrl is the relative same-origin path /api (no hardcoded host)', () => { + expect(routes.apiUrl).toBe('/api'); + }); +}); diff --git a/Club12-WebClient/src/modules/core/constants/routes.ts b/Club12-WebClient/src/modules/core/constants/routes.ts new file mode 100644 index 0000000..981da42 --- /dev/null +++ b/Club12-WebClient/src/modules/core/constants/routes.ts @@ -0,0 +1,32 @@ +const routes = { + apiUrl: '/api', + + auditLogs: 'audit-logs', + backups: 'backups', + clubs: 'clubs', + blogposts: 'blogposts', + champions: 'champions', + maintenance: 'maintenance', + dataMaintenance: 'data-maintenance', + divisions: 'divisions', + stages: 'stages', + matches: 'matches', + matchSeries: 'match-series', + medicalRecords: 'medical-records', + players: 'players', + seasons: 'seasons', + scorer: 'scorer', + playerSanctions: 'player-sanctions', + pointDeductions: 'point-deductions', + playerStatistics: 'player-statistics', + statistics: 'statistics', + staff: 'staff', + teams: 'teams', + tournaments: 'tournaments', + users: 'users', + auth: 'auth', + venues: 'venues', + tokenInvalido: '/token-invalido', +}; + +export default routes; diff --git a/Club12-WebClient/src/modules/core/enum/match/matchStatus.ts b/Club12-WebClient/src/modules/core/enum/match/matchStatus.ts new file mode 100644 index 0000000..b67bd9b --- /dev/null +++ b/Club12-WebClient/src/modules/core/enum/match/matchStatus.ts @@ -0,0 +1,34 @@ +/** + * The lifecycle status of a match (HU-69/HU-73). Mirrors the backend + * `Domain.Enums.MatchStatus` and is serialized as a string on the match + * response DTOs. + * @enum MatchStatus + */ +export enum MatchStatus { + /** + * The match has a fixture but no result loaded yet. + */ + Scheduled = 'Scheduled', + + /** + * A normal result was loaded (HU-69); the match has a winner. + */ + Played = 'Played', + + /** + * The match was suspended (HU-68/HU-73) and awaits rescheduling. + */ + Suspended = 'Suspended', + + /** + * A walkover was applied (HU-73): the present team was awarded the + * regulation default result. Distinguishable from a normal `Played`. + */ + WalkOver = 'WalkOver', + + /** + * The match will never be played: its tournament was canceled, or + * force-closed as finished while the match was still pending. + */ + Canceled = 'Canceled', +} diff --git a/Club12-WebClient/src/modules/core/enum/match/matchType.ts b/Club12-WebClient/src/modules/core/enum/match/matchType.ts new file mode 100644 index 0000000..acc0820 --- /dev/null +++ b/Club12-WebClient/src/modules/core/enum/match/matchType.ts @@ -0,0 +1,17 @@ +/** + * The types of matches that can exist (Regular or Playoff). + * @enum MatchType + */ +export enum MatchType { + /** + * A regular match in the tournament. + * @type {string} + */ + Regular = 'Regular', + + /** + * A playoff match in the tournament. + * @type {string} + */ + Playoff = 'Playoff', +} diff --git a/Club12-WebClient/src/modules/core/enum/medicalRecord/medicalRecordStatus.ts b/Club12-WebClient/src/modules/core/enum/medicalRecord/medicalRecordStatus.ts new file mode 100644 index 0000000..9513b9c --- /dev/null +++ b/Club12-WebClient/src/modules/core/enum/medicalRecord/medicalRecordStatus.ts @@ -0,0 +1,29 @@ +/** + * The medical-record / eligibility status of a player's season registration + * (HU-57). Mirrors the backend `Domain.Enums.MedicalRecordStatus` and is + * serialized as a string on the medical-record and roster response DTOs. + * + * The status is scoped per player + team + tournament, so being `Approved` + * in one season never carries over to another (HU-59). A player is + * "habilitado" only when the record is {@link Approved}. + * @enum MedicalRecordStatus + */ +export enum MedicalRecordStatus { + /** + * No medical record uploaded yet, or uploaded but not reviewed. The player + * is NOT habilitado. + */ + Pending = 'Pending', + + /** + * The owner/admin reviewed and approved the record (HU-58): the player is + * habilitado for that team and tournament (HU-57). + */ + Approved = 'Approved', + + /** + * The owner/admin rejected the record (HU-58), usually with a reason. The + * player is NOT habilitado. + */ + Rejected = 'Rejected', +} diff --git a/Club12-WebClient/src/modules/core/enum/tournament/tournamentCategory.ts b/Club12-WebClient/src/modules/core/enum/tournament/tournamentCategory.ts new file mode 100644 index 0000000..d9379b6 --- /dev/null +++ b/Club12-WebClient/src/modules/core/enum/tournament/tournamentCategory.ts @@ -0,0 +1,22 @@ +/** + * Competitive category (gender) of a tournament (HU-48). The feminine + * competition is, by club rule, a SEPARATE tournament: a single tournament can + * never mix feminine and masculine divisions. The category lives on the + * tournament and every one of its divisions must share it. + * + * Values mirror the backend `Domain.Enums.TournamentCategory` names exactly + * (the API serializes enums as strings via JsonStringEnumConverter). + */ +export const TournamentCategory = { + Masculine: 'Masculine', + Feminine: 'Feminine', +} as const; + +export type TournamentCategory = + (typeof TournamentCategory)[keyof typeof TournamentCategory]; + +/** Spanish display labels for each category, shared by the wizard and views. */ +export const TOURNAMENT_CATEGORY_LABELS: Record = { + [TournamentCategory.Masculine]: 'Masculino', + [TournamentCategory.Feminine]: 'Femenino', +}; diff --git a/Club12-WebClient/src/modules/core/enum/tournament/tournamentStatus.ts b/Club12-WebClient/src/modules/core/enum/tournament/tournamentStatus.ts new file mode 100644 index 0000000..ea58e8b --- /dev/null +++ b/Club12-WebClient/src/modules/core/enum/tournament/tournamentStatus.ts @@ -0,0 +1,11 @@ +export const TournamentStatus = { + Scheduled: 'Scheduled', + OpenForRegistration: 'OpenForRegistration', + RegistrationClosed: 'RegistrationClosed', + Ongoing: 'Ongoing', + Finished: 'Finished', + Canceled: 'Canceled', +} as const; + +export type TournamentStatus = + (typeof TournamentStatus)[keyof typeof TournamentStatus]; diff --git a/Club12-WebClient/src/modules/core/enum/user/userRolesType.ts b/Club12-WebClient/src/modules/core/enum/user/userRolesType.ts new file mode 100644 index 0000000..733026b --- /dev/null +++ b/Club12-WebClient/src/modules/core/enum/user/userRolesType.ts @@ -0,0 +1,22 @@ +export const UserRolesType = { + Admin: 'ADMIN', + Owner: 'OWNER', + Guest: 'GUEST', +} as const; + +export type UserRolesType = (typeof UserRolesType)[keyof typeof UserRolesType]; + +/** + * Canonical Spanish display labels for every role. Co-located with the enum + * so every view (users list, create-user form, edit-user form) shows the + * same wording instead of each screen inventing its own. + * + * HU-05: the role model is reduced to Owner and Admin (plus the technical + * Guest). `Tournament Manager` and `Team Manager` were removed from the + * system and the UI. + */ +export const USER_ROLE_LABELS: Record = { + [UserRolesType.Admin]: 'Admin', + [UserRolesType.Owner]: 'Owner', + [UserRolesType.Guest]: 'Invitado', +}; diff --git a/Club12-WebClient/src/modules/core/types/types.d.ts b/Club12-WebClient/src/modules/core/types/types.d.ts new file mode 100644 index 0000000..66b8957 --- /dev/null +++ b/Club12-WebClient/src/modules/core/types/types.d.ts @@ -0,0 +1,43 @@ +import { ReactNode } from 'react'; +import { Order } from '@/modules/core/constants/order'; +export interface ProviderProps { + children: ReactNode; +} + +export interface GenericResponsePagination { + items: T[]; + page: number; + pageSize: number; + totalCount: number; +} + +export interface Filtered { + pageNumber?: number; + pageSize?: number; + orderBy?: string; + order?: Order; +} + +/** + * Per-call options for a context GET method. `silent` suppresses the global + * blocking alert on failure so a public page can render a quiet inline retry + * state instead — mutations (save/delete) never pass it and keep their alerts. + * `force` skips the context's "already have it in the local list" cache hit + * and re-fetches from the server — needed right after a mutation that + * changes data nested inside the cached item (e.g. deleting a tournament + * from within a season), which the cache hit would otherwise mask. + */ +export interface FetchOptions { + silent?: boolean; + force?: boolean; +} + +export type RequestProps = { + method: string; + resource: string; + configOverride?: object; + body?: unknown; + query?: object; +}; + +export type GUID = `${string}-${string}-${string}-${string}-${string}`; diff --git a/Club12-WebClient/src/modules/core/utils/axiosUtils.test.ts b/Club12-WebClient/src/modules/core/utils/axiosUtils.test.ts new file mode 100644 index 0000000..54b5786 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/axiosUtils.test.ts @@ -0,0 +1,174 @@ +import { AxiosError } from 'axios'; +import axios from 'axios'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sendDelete, sendGet, sendPost } from './axiosUtils'; +import { getActiveRequestCount } from './requestActivity'; + +const originalLocation = window.location; + +const mockAssign = (): ReturnType => { + const assignSpy = vi.fn(); + Object.defineProperty(window, 'location', { + configurable: true, + value: { ...originalLocation, assign: assignSpy }, + }); + return assignSpy; +}; + +vi.mock('axios', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual.default, + request: vi.fn(), + }, + }; +}); + +const buildUnauthorizedError = (hasAuthHeader: boolean): AxiosError => + ({ + isAxiosError: true, + name: 'AxiosError', + message: 'Request failed with status code 401', + config: { + headers: hasAuthHeader ? { Authorization: 'Bearer expired-token' } : {}, + }, + response: { + status: 401, + data: {}, + statusText: 'Unauthorized', + headers: {}, + config: { headers: {} }, + }, + toJSON: () => ({}), + }) as unknown as AxiosError; + +describe('axiosUtils invalid-token redirect', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + }); + + it('redirects to /token-invalido when a 401 error carries an Authorization header', async () => { + const assignSpy = mockAssign(); + vi.mocked(axios.request).mockRejectedValueOnce( + buildUnauthorizedError(true) + ); + + await expect(sendDelete('divisions/123')).rejects.toBeTruthy(); + + expect(assignSpy).toHaveBeenCalledWith('/token-invalido'); + }); + + it('does NOT redirect when a 401 error carries no Authorization header (and is not a refresh-token request)', async () => { + const assignSpy = mockAssign(); + vi.mocked(axios.request).mockRejectedValueOnce( + buildUnauthorizedError(false) + ); + + await expect(sendDelete('divisions/123')).rejects.toBeTruthy(); + + expect(assignSpy).not.toHaveBeenCalled(); + }); +}); + +const buildNotFoundError = (): AxiosError => + ({ + isAxiosError: true, + name: 'AxiosError', + message: 'Request failed with status code 404', + config: { headers: {} }, + response: { + status: 404, + data: { + title: 'Not Found: The specified resource could not be found.', + detail: 'Division with id 123 not found.', + status: 404, + }, + statusText: 'Not Found', + headers: {}, + config: { headers: {} }, + }, + toJSON: () => ({}), + }) as unknown as AxiosError; + +describe('sendGet error pipeline', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + Object.defineProperty(window, 'location', { + configurable: true, + value: originalLocation, + }); + }); + + it('redirects to /token-invalido when a GET request gets a 401 with an Authorization header', async () => { + const assignSpy = mockAssign(); + vi.mocked(axios.request).mockRejectedValueOnce( + buildUnauthorizedError(true) + ); + + await expect(sendGet('divisions/123')).rejects.toBeTruthy(); + + expect(assignSpy).toHaveBeenCalledWith('/token-invalido'); + }); + + it('rejects with the same error shape as other verbs when a GET request gets a 404', async () => { + const notFoundError = buildNotFoundError(); + vi.mocked(axios.request).mockRejectedValueOnce(notFoundError); + + await expect(sendGet('divisions/123')).rejects.toBe(notFoundError); + }); +}); + +describe('mutating-request activity tracking (drives GlobalLoadingOverlay)', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('increments the active count while a POST is in flight, then releases it on success', async () => { + let resolveRequest: (value: unknown) => void = () => {}; + const pending = new Promise(resolve => { + resolveRequest = resolve; + }); + vi.mocked(axios.request).mockReturnValueOnce(pending as never); + + expect(getActiveRequestCount()).toBe(0); + const requestPromise = sendPost('teams', { name: 'River' }); + await Promise.resolve(); + expect(getActiveRequestCount()).toBe(1); + + resolveRequest({ data: {}, status: 201, statusText: 'Created', headers: {}, config: {} }); + await requestPromise; + expect(getActiveRequestCount()).toBe(0); + }); + + it('releases the active count even when the mutating request fails', async () => { + vi.mocked(axios.request).mockRejectedValueOnce(buildNotFoundError()); + + await expect(sendPost('teams', { name: 'River' })).rejects.toBeTruthy(); + expect(getActiveRequestCount()).toBe(0); + }); + + it('does NOT affect the active count for a GET (page data keeps its own skeleton loading)', async () => { + vi.mocked(axios.request).mockResolvedValueOnce({ + data: {}, + status: 200, + statusText: 'OK', + headers: {}, + config: {}, + }); + + await sendGet('teams'); + expect(getActiveRequestCount()).toBe(0); + }); +}); diff --git a/Club12-WebClient/src/modules/core/utils/axiosUtils.ts b/Club12-WebClient/src/modules/core/utils/axiosUtils.ts new file mode 100644 index 0000000..a02d10e --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/axiosUtils.ts @@ -0,0 +1,349 @@ +import axios, { AxiosError, AxiosResponse } from 'axios'; +import jsCookie from 'js-cookie'; +import routes from '@/modules/core/constants/routes'; +import { + ERROR_MESSAGES, + COOKIE_SIGNIN_TOKEN, + JWT, +} from '@/modules/core/constants/constants'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; +import { beginRequest, endRequest } from '@/modules/core/utils/requestActivity'; + +const TOKEN_KEY: string = COOKIE_SIGNIN_TOKEN; +const INVALID_TOKEN_PATH = routes.tokenInvalido; + +type headersContent = { + 'Content-Type'?: string; + Authorization?: string; +}; + +type ConfigOverride = { + headers?: headersContent; +}; + +const statusCodeHandlers: Record< + number, + ((response: AxiosResponse) => void)[] +> = {}; + +const hasAuthorizationHeader = (error: AxiosError): boolean => { + const headers = error.config?.headers as Record | undefined; + + const authorizationHeader = headers?.Authorization ?? headers?.authorization; + return Boolean(authorizationHeader); +}; + +const isRefreshTokenRequest = (error: AxiosError): boolean => { + const requestUrl = error.config?.url ?? ''; + return requestUrl.includes('/auth/refresh-token'); +}; + +const redirectToInvalidToken = (): void => { + if (typeof window === 'undefined') { + return; + } + + if (window.location.pathname === INVALID_TOKEN_PATH) { + return; + } + + unregisterToken(); + localStorage.removeItem(JWT.REFRESH_TOKEN); + window.location.assign(INVALID_TOKEN_PATH); +}; + +const triggerStatusCodeHandlers = (response: AxiosResponse): void => { + const handlers = statusCodeHandlers[response.status]; + if (!handlers?.length) { + return; + } + + handlers.forEach(callback => { + callback(response); + }); +}; + +const handleUnauthorizedToken = (error: AxiosError): void => { + const statusCode = error.response?.status; + if (statusCode !== HttpStatus.Unauthorized) { + return; + } + + const shouldRedirect = + hasAuthorizationHeader(error) || isRefreshTokenRequest(error); + + if (shouldRedirect) { + redirectToInvalidToken(); + } +}; + +/** + * Checks if a token is set in cookies. + * @returns {boolean} True if the token exists, false otherwise. + */ +export const tokenIsSet = (): boolean => !!jsCookie.get(TOKEN_KEY); + +/** + * Registers a token in cookies. + * @param {string} newToken - The token to store. + * @param {Date} expirationDate - The expiration date for the token. + */ +export const registerToken = (newToken: string, expirationDate: Date) => { + jsCookie.set(TOKEN_KEY, newToken, { + expires: expirationDate, + sameSite: 'lax', + path: '/', + }); +}; + +/** + * Unregisters (removes) the token from cookies. + */ +export const unregisterToken = (): void => { + jsCookie.remove(TOKEN_KEY, { + path: '/', + }); +}; + +/** + * Retrieves the currently registered token. + * @returns {string | undefined} The registered token, or undefined if none is set. + */ +export const getRegisteredToken = (): string | undefined => + jsCookie.get(TOKEN_KEY); + +/** + * Retrieves the default headers for requests. + * @returns {headersContent} The default headers. + */ +const getDefaultHeaders = (): headersContent => { + const headers: headersContent = { + 'Content-Type': 'application/json; charset=utf-8', + }; + + if (tokenIsSet()) { + const token = jsCookie.get(TOKEN_KEY); + headers.Authorization = `Bearer ${token}`; + } + return headers; +}; + +/** + * Merges custom headers with default headers. + * @param {ConfigOverride} [configOverride] - The override configuration. + * @returns {headersContent} The resulting headers. + */ +const getHeaders = (configOverride?: ConfigOverride): headersContent => { + let headers: headersContent = getDefaultHeaders(); + + if (configOverride?.headers) { + headers = { + ...headers, + ...configOverride.headers, + }; + } + + return headers; +}; + +/** + * Builds the full API endpoint URL, including optional query parameters. + * @param {string} resource - The API resource. + * @param {object} [query] - The query parameters as an object. + * @returns {string} The encoded full endpoint URL. + */ +export const buildEndpoint = (resource: string, query?: object): string => { + const finalResource = `${routes.apiUrl}/${resource}`; + if (query) { + const queryParams = Object.entries(query) + .filter( + ([, value]) => value !== undefined && value !== null && value !== '' + ) + .map( + ([key, value]) => + `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}` + ) + .join('&'); + + if (queryParams.length > 0) { + return `${finalResource}?${queryParams}`; + } + } + + return finalResource; +}; + +/** + * Sends an HTTP request. + * @param {string} method - HTTP method (GET, POST, PUT, DELETE). + * @param {string} resource - API resource. + * @param {object} [configOverride] - Request configuration overrides. + * @param {unknown | null} body - Request body data. + * @param {object} [query] - Query parameters. + * @returns {Promise>} A promise that resolves with the response or undefined. + */ +const sendRequest = async ( + method: string, + resource: string, + configOverride: object = {}, + body: unknown | null = null, + query?: object +): Promise> => { + const headers = getHeaders(configOverride); + if (body instanceof FormData) { + delete headers['Content-Type']; + } + const url = buildEndpoint(resource, query); + + // Mutations (loading/uploading/saving something) block the whole screen + // via GlobalLoadingOverlay; GETs keep their own skeleton-loading pattern. + const isMutation = method !== 'GET'; + if (isMutation) { + beginRequest(); + } + + try { + const result: AxiosResponse = await axios.request({ + method, + url, + headers, + data: body, + }); + return result; + } catch (error: unknown) { + throw throwError(error); + } finally { + if (isMutation) { + endRequest(); + } + } +}; + +/** + * Throws an appropriate error based on its type. + * @param {unknown} error - The error object. + * @returns {AxiosError | Error} - The error object processed. + */ +const throwError = (error: unknown): AxiosError | Error => { + switch (true) { + case axios.isAxiosError(error): { + if (error.response) { + triggerStatusCodeHandlers(error.response); + } + handleUnauthorizedToken(error); + return error; + } + + case error instanceof Error: + return new AxiosError( + error.message, + undefined, + undefined, + undefined, + undefined + ); + + default: + return new AxiosError(ERROR_MESSAGES.GENERIC_ERROR); + } +}; + +/** + * Sends a POST HTTP request. + * @param {string} resource - API resource. + * @param {unknown} [body] - Request body. + * @param {ConfigOverride} [configOverride] - Configuration overrides. + * @returns {Promise>} A promise that resolves with the server response. + */ +export const sendPost = async ( + resource: string, + body?: unknown, + configOverride?: ConfigOverride +): Promise> => { + return await sendRequest('POST', resource, configOverride, body); +}; + +/** + * Sends a PUT HTTP request. + * @param {string} resource - API resource. + * @param {unknown} body - Request body. + * @param {ConfigOverride} [configOverride] - Configuration overrides. + * @returns {Promise>} A promise that resolves with the server response. + */ +export const sendPut = async ( + resource: string, + body: unknown, + configOverride?: ConfigOverride +): Promise> => { + return await sendRequest('PUT', resource, configOverride, body); +}; + +/** + * Sends a GET HTTP request. + * @param {string} resource - API resource. + * @param {object} [query] - Query parameters. + * @returns {Promise>} A promise that resolves with the server response. + */ +export const sendGet = async ( + resource: string, + query?: object +): Promise> => { + return await sendRequest('GET', resource, {}, null, query); +}; + +/** + * Sends a DELETE HTTP request. + * @param {string} resource - API resource. + * @param {ConfigOverride} [configOverride] - Configuration overrides. + * @returns {Promise>} A promise that resolves when the resource is deleted. + */ +export const sendDelete = async ( + resource: string, + configOverride?: ConfigOverride, + body?: unknown +): Promise> => + await sendRequest('DELETE', resource, configOverride, body); + +/** + * Downloads a file from the server. + * @param {string} resource - API resource. + * @param {string} fileNameWithExtension - Name of the file to save locally. + */ +export const downloadfile = async ( + resource: string, + fileNameWithExtension: string +) => { + const headers = getHeaders(); + const url = buildEndpoint(resource); + + const result = await axios.get(url, { headers, responseType: 'blob' }); + + const fileUrl = window.URL.createObjectURL(new Blob([result.data])); + const link = document.createElement('a'); + link.href = fileUrl; + link.setAttribute('download', fileNameWithExtension); + document.body.appendChild(link); + link.click(); + link.remove(); +}; + +/** + * Registers a callback for a specific HTTP status code. + * @param {number} statusCode - HTTP status code. + * @param {() => unknown} callback - Function to execute when the status code is received. + */ +export const onStatusCode = (statusCode: number, callback: () => unknown) => { + if (statusCodeHandlers[statusCode]) { + statusCodeHandlers[statusCode].push(callback); + } else { + statusCodeHandlers[statusCode] = [callback]; + } +}; + +/** + * Registers a callback for the 401 Unauthorized status code. + * @param {() => unknown} callback - Function to execute when a 401 status code is received. + */ +export const onUnauthorized = (callback: () => unknown) => { + onStatusCode(HttpStatus.Unauthorized, callback); +}; diff --git a/Club12-WebClient/src/modules/core/utils/comparator.ts b/Club12-WebClient/src/modules/core/utils/comparator.ts new file mode 100644 index 0000000..ffc047f --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/comparator.ts @@ -0,0 +1,52 @@ +import { AxiosResponse } from 'axios'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; + +interface ListItemWithId { + id: GUID; +} + +/** + * Helper para realizar un fetch de una lista de la API y actualizar el estado condicionalmente. + * La llamada a la API siempre se realiza. El estado solo se actualiza si los datos han cambiado. + * + * @template T + * @template F + * @param {Object} options + * @param {(filter: F) => Promise>>} apiCall + * @param {T[] | null} currentState + * @param {React.Dispatch>} setState + * @param {F} filter + * @returns {Promise | void>} + */ +export async function fetchAndSetList(options: { + apiCall: (filter: F) => Promise>>; + currentState: T[] | null; + setState: React.Dispatch>; + filter: F; +}): Promise | void> { + const { apiCall, currentState, setState, filter } = options; + + const res: AxiosResponse> = + await apiCall(filter); + + if (res && res.data) { + const newItems = res.data.items; + + const currentIds = (currentState || []) + .map(item => item.id) + .sort() + .join(','); + const newIds = newItems + .map(item => item.id) + .sort() + .join(','); + + if (newIds !== currentIds) { + setState(newItems); + } + + return res.data; + } + + return undefined; +} diff --git a/Club12-WebClient/src/modules/core/utils/confirmDialog.ts b/Club12-WebClient/src/modules/core/utils/confirmDialog.ts new file mode 100644 index 0000000..fd22882 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/confirmDialog.ts @@ -0,0 +1,117 @@ +import Swal from 'sweetalert2'; +import { CANCEL_BUTTON_COLOR, getTheme } from '@/theme'; + +const theme = getTheme('dark'); +const DIALOG_BACKGROUND = theme.palette.background.paper; +const DIALOG_TEXT_COLOR = theme.palette.text.primary; + +// SweetAlert's container defaults to z-index 1060, which sits BELOW MUI's +// modals/dialogs (1300+). A confirm/notify fired while a MUI Dialog is open +// then renders behind it — its buttons unclickable and the awaited promise +// never resolving. Lifting the container above every MUI layer keeps alerts +// on top wherever they are triggered from. +const OVER_MUI_ZINDEX = '2000'; + +const liftAboveMuiModals = () => { + const container = Swal.getContainer(); + if (container) { + container.style.zIndex = OVER_MUI_ZINDEX; + } +}; + +type NotifyIcon = 'success' | 'error' | 'warning' | 'info'; + +interface NotifyOptions { + title: string; + text?: string; +} + +interface ConfirmActionOptions { + title: string; + text?: string; + icon?: 'warning' | 'question'; + confirmButtonText?: string; + cancelButtonText?: string; + confirmButtonColor?: string; + cancelButtonColor?: string; +} + +interface ConfirmDeleteOptions { + title: string; + text: string; + confirmButtonText?: string; +} + +async function notify(icon: NotifyIcon, options: NotifyOptions): Promise { + await Swal.fire({ + title: options.title, + text: options.text, + icon, + confirmButtonColor: theme.palette.primary.main, + background: DIALOG_BACKGROUND, + color: DIALOG_TEXT_COLOR, + didOpen: liftAboveMuiModals, + }); +} + +/** + * Shows a standardized success dialog using the app theme colors. + * @param options - Title and optional text. + */ +export const notifySuccess = (options: NotifyOptions): Promise => + notify('success', options); + +/** + * Shows a standardized error dialog using the app theme colors. + * @param options - Title and optional text. + */ +export const notifyError = (options: NotifyOptions): Promise => + notify('error', options); + +/** + * Shows a standardized warning dialog using the app theme colors. + * Intended for validation messages that do not require confirmation. + * @param options - Title and optional text. + */ +export const notifyWarning = (options: NotifyOptions): Promise => + notify('warning', options); + +/** + * Shows a standardized info dialog using the app theme colors. + * @param options - Title and optional text. + */ +export const notifyInfo = (options: NotifyOptions): Promise => + notify('info', options); + +/** + * Shows a standardized confirm/cancel dialog. Button colors default to the + * app theme but can be overridden for semantic actions (e.g. a destructive + * action in red, an activation action in green). + * @param options - Title, text, icon and optional button text/colors. + * @returns `true` if the user confirmed the action, `false` otherwise. + */ +export async function confirmAction(options: ConfirmActionOptions): Promise { + const result = await Swal.fire({ + title: options.title, + text: options.text, + icon: options.icon ?? 'warning', + showCancelButton: true, + confirmButtonColor: options.confirmButtonColor ?? theme.palette.primary.main, + cancelButtonColor: options.cancelButtonColor ?? CANCEL_BUTTON_COLOR, + confirmButtonText: options.confirmButtonText ?? 'Confirmar', + cancelButtonText: options.cancelButtonText ?? 'Cancelar', + background: DIALOG_BACKGROUND, + color: DIALOG_TEXT_COLOR, + didOpen: liftAboveMuiModals, + }); + + return result.isConfirmed; +} + +/** + * Shows a standardized delete-confirmation dialog using the app theme colors. + * @param options - Title, text and optional confirm button text. + * @returns `true` if the user confirmed the action, `false` otherwise. + */ +export const confirmDelete = (options: ConfirmDeleteOptions): Promise => + confirmAction({ ...options, confirmButtonText: options.confirmButtonText ?? 'Sí, eliminar' }); diff --git a/Club12-WebClient/src/modules/core/utils/csv.test.ts b/Club12-WebClient/src/modules/core/utils/csv.test.ts new file mode 100644 index 0000000..644238c --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/csv.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; +import { buildCsv, parseCsv } from '@/modules/core/utils/csv'; + +describe('buildCsv', () => { + it('emits the header row followed by data rows, CRLF-separated', () => { + const csv = buildCsv( + ['#', 'Jugador', 'Puntos'], + [ + [1, 'Ana Gómez', 12], + [2, 'Beto Ruiz', 9], + ] + ); + + expect(csv).toBe( + '#,Jugador,Puntos\r\n1,Ana Gómez,12\r\n2,Beto Ruiz,9' + ); + }); + + it('quotes cells containing commas, quotes or line breaks and doubles inner quotes', () => { + const csv = buildCsv( + ['Equipo', 'Nota'], + [ + ['Club, 12', 'dijo "hola"'], + ['Salto\nAlto', 'ok'], + ] + ); + + const lines = csv.split('\r\n'); + expect(lines[0]).toBe('Equipo,Nota'); + expect(lines[1]).toBe('"Club, 12","dijo ""hola"""'); + // A cell with a newline is wrapped in quotes, so the record spans two lines. + expect(csv).toContain('"Salto\nAlto",ok'); + }); + + it('renders null and undefined cells as empty strings', () => { + const csv = buildCsv(['A', 'B', 'C'], [[null, undefined, 0]]); + + expect(csv).toBe('A,B,C\r\n,,0'); + }); +}); + +describe('parseCsv', () => { + it('splits the header from data rows and trims header whitespace', () => { + const parsed = parseCsv('Nombre,Apellido\r\nAna,Gómez\r\nBeto,Ruiz'); + + expect(parsed.headers).toEqual(['Nombre', 'Apellido']); + expect(parsed.rows).toEqual([ + ['Ana', 'Gómez'], + ['Beto', 'Ruiz'], + ]); + }); + + it('unescapes quoted cells containing commas and doubled quotes', () => { + const parsed = parseCsv( + 'Equipo,Nota\r\n"Club, 12","dijo ""hola"""' + ); + + expect(parsed.rows).toEqual([['Club, 12', 'dijo "hola"']]); + }); + + it('strips a leading UTF-8 BOM and skips blank lines', () => { + const parsed = parseCsv('A,B\r\n1,2\r\n\r\n3,4\r\n'); + + expect(parsed.headers).toEqual(['A', 'B']); + expect(parsed.rows).toEqual([ + ['1', '2'], + ['3', '4'], + ]); + }); + + it('returns empty headers/rows for empty input', () => { + expect(parseCsv('')).toEqual({ headers: [], rows: [] }); + }); + + it('round-trips what buildCsv produces', () => { + const csv = buildCsv( + ['Nombre', 'Nota'], + [['Ana, con coma', 'dijo "hola"']] + ); + + const parsed = parseCsv(csv); + + expect(parsed.headers).toEqual(['Nombre', 'Nota']); + expect(parsed.rows).toEqual([['Ana, con coma', 'dijo "hola"']]); + }); +}); diff --git a/Club12-WebClient/src/modules/core/utils/csv.ts b/Club12-WebClient/src/modules/core/utils/csv.ts new file mode 100644 index 0000000..8d0c725 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/csv.ts @@ -0,0 +1,134 @@ +/** + * Minimal, dependency-free CSV export helper (HU-89). Turns a header row plus a + * matrix of cells into an RFC-4180-style CSV string and triggers a client-side + * download, so standings, goleadores and fixtures can be shared outside the app + * without any server round-trip. + */ + +export type CsvCellValue = string | number | boolean | null | undefined; +export type CsvRow = CsvCellValue[]; + +const CSV_DELIMITER = ','; +/** RFC-4180 records are separated by CRLF; Excel and Sheets both expect it. */ +const CSV_LINE_BREAK = '\r\n'; +/** + * UTF-8 BOM prepended to the download so spreadsheet apps (notably Excel on + * Windows) detect UTF-8 and render accented characters (á, ó, ñ) correctly. + */ +const UTF8_BOM = ''; + +/** + * Escapes a single CSV cell: renders null/undefined as empty, and wraps the + * value in double quotes (doubling any inner quote) whenever it contains a + * delimiter, a quote or a line break. + */ +const escapeCsvCell = (value: CsvCellValue): string => { + if (value === null || value === undefined) { + return ''; + } + + const text = String(value); + if (/[",\r\n]/.test(text)) { + return `"${text.replace(/"/g, '""')}"`; + } + + return text; +}; + +/** + * Builds a CSV string from a header row and data rows. Pure and + * side-effect-free so it can be unit-tested in isolation. + */ +export const buildCsv = (headers: string[], rows: CsvRow[]): string => + [headers, ...rows] + .map(row => row.map(escapeCsvCell).join(CSV_DELIMITER)) + .join(CSV_LINE_BREAK); + +/** + * Builds a CSV from the given headers/rows and triggers a browser download of + * it as `.csv`. + */ +export const downloadCsv = ( + filename: string, + headers: string[], + rows: CsvRow[] +): void => { + const csv = `${UTF8_BOM}${buildCsv(headers, rows)}`; + const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename.toLowerCase().endsWith('.csv') + ? filename + : `${filename}.csv`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + +export interface ParsedCsv { + headers: string[]; + rows: string[][]; +} + +/** Splits one CSV record into cells, undoing {@link escapeCsvCell}'s + * double-quote escaping (a `""` inside a quoted cell is a literal `"`). */ +const parseCsvLine = (line: string): string[] => { + const cells: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < line.length; i++) { + const char = line[i]; + + if (inQuotes) { + if (char === '"') { + if (line[i + 1] === '"') { + current += '"'; + i++; + } else { + inQuotes = false; + } + } else { + current += char; + } + continue; + } + + if (char === '"') { + inQuotes = true; + } else if (char === CSV_DELIMITER) { + cells.push(current); + current = ''; + } else { + current += char; + } + } + + cells.push(current); + return cells; +}; + +/** + * Parses CSV text — the counterpart to {@link buildCsv} — into a header row + * and data rows of raw string cells. Blank lines are skipped so a trailing + * newline (or one left over from editing in a spreadsheet app) doesn't turn + * into a spurious empty row. + */ +export const parseCsv = (text: string): ParsedCsv => { + const lines = text + .replace(/^\uFEFF/, '') + .split(/\r\n|\n/) + .filter(line => line.trim() !== ''); + + if (lines.length === 0) { + return { headers: [], rows: [] }; + } + + const [headerLine, ...dataLines] = lines; + return { + headers: parseCsvLine(headerLine).map(cell => cell.trim()), + rows: dataLines.map(parseCsvLine), + }; +}; diff --git a/Club12-WebClient/src/modules/core/utils/formUtils.ts b/Club12-WebClient/src/modules/core/utils/formUtils.ts new file mode 100644 index 0000000..af5cbe3 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/formUtils.ts @@ -0,0 +1,5 @@ +export function handleFields(event: React.FormEvent) { + event.preventDefault(); + const fields = Object.fromEntries(new window.FormData(event.currentTarget)); + return fields; +} diff --git a/Club12-WebClient/src/modules/core/utils/formatDate.test.ts b/Club12-WebClient/src/modules/core/utils/formatDate.test.ts new file mode 100644 index 0000000..26e910d --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/formatDate.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { + formatCalendarDate, + formatDateAr, + formatDateTimeAr, + formatTimeAr, + formatLongDateTimeAr, + toArDayKey, + formatArDayLabel, +} from './formatDate'; + +// Argentina (America/Argentina/Buenos_Aires) is UTC-3 year-round, so a UTC +// instant always renders three hours earlier. These assertions must hold +// regardless of the machine timezone the test runs on (HU-100). +describe('Argentina-time formatting helpers', () => { + it('formats a known UTC instant as AR date + time', () => { + expect(formatDateTimeAr('2026-08-16T17:30:00Z')).toBe('16/08/2026 14:30'); + }); + + it('formats the date portion in AR time', () => { + expect(formatDateAr('2026-08-16T17:30:00Z')).toBe('16/08/2026'); + }); + + it('formats the time portion in AR time', () => { + expect(formatTimeAr('2026-08-16T17:30:00Z')).toBe('14:30'); + }); + + it('rolls back to the previous AR day for an early-UTC instant', () => { + // 02:00 UTC on Jan 1 is 23:00 the previous day in Buenos Aires. + expect(formatDateAr('2026-01-01T02:00:00Z')).toBe('31/12/2025'); + expect(formatTimeAr('2026-01-01T02:00:00Z')).toBe('23:00'); + expect(toArDayKey('2026-01-01T02:00:00Z')).toBe('2025-12-31'); + }); + + it('produces a long Spanish AR date-time label', () => { + expect(formatLongDateTimeAr('2026-08-16T17:30:00Z')).toContain( + '16 de agosto de 2026 • 14:30' + ); + }); + + it('labels a day key with a capitalized Spanish weekday', () => { + expect(formatArDayLabel('2026-08-16')).toContain('16 de agosto'); + expect(formatArDayLabel('2026-08-16')[0]).toBe( + formatArDayLabel('2026-08-16')[0].toUpperCase() + ); + }); + + it('returns placeholders for empty or invalid input', () => { + expect(formatDateAr('')).toBe('—'); + expect(formatDateTimeAr(null)).toBe('—'); + expect(formatDateAr('not-a-date')).toBe('—'); + expect(toArDayKey('')).toBe('unknown'); + }); +}); + +// A pure calendar date (tournament start date, registration deadline, birth +// date) is submitted from an as UTC midnight of the +// intended day ("2026-10-10" -> new Date() -> "2026-10-10T00:00:00.000Z"). +// It has no real time-of-day, so — unlike a genuine instant — it must NOT be +// shifted into Argentina time on display: formatDateAr would roll UTC +// midnight back to 21:00 the previous day in Buenos Aires (UTC-3), +// displaying the wrong day for every viewer west of UTC. +describe('formatCalendarDate — pure date fields, no timezone shift', () => { + it('renders UTC midnight as the same calendar day, not the previous one', () => { + expect(formatCalendarDate('2026-10-10T00:00:00.000Z')).toBe('10/10/2026'); + // formatDateAr on the same value demonstrates the bug this guards against. + expect(formatDateAr('2026-10-10T00:00:00.000Z')).toBe('09/10/2026'); + }); + + it('returns a placeholder for empty or invalid input', () => { + expect(formatCalendarDate('')).toBe('—'); + expect(formatCalendarDate(null)).toBe('—'); + expect(formatCalendarDate('not-a-date')).toBe('—'); + }); +}); diff --git a/Club12-WebClient/src/modules/core/utils/formatDate.ts b/Club12-WebClient/src/modules/core/utils/formatDate.ts new file mode 100644 index 0000000..56b5f4a --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/formatDate.ts @@ -0,0 +1,176 @@ +import dayjs, { Dayjs } from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import timezone from 'dayjs/plugin/timezone'; +import 'dayjs/locale/es'; + +dayjs.extend(utc); +dayjs.extend(timezone); + +/** + * Canonical display timezone for the whole app (HU-100). The backend stores + * and returns instants in UTC; every user-facing date/time is presented in + * Argentina time regardless of the viewer's own timezone. + */ +export const AR_TIMEZONE = 'America/Argentina/Buenos_Aires'; + +/** Parses a UTC value and shifts it to Argentina time. */ +const toArDayjs = (value: Date | string): Dayjs => + dayjs.utc(value).tz(AR_TIMEZONE); + +/** + * Formats a UTC value as a short Argentina-time date, e.g. "16/08/2026". + * Returns "—" for empty or unparseable input. + */ +export function formatDateAr(value?: Date | string | null): string { + if (!value) return '—'; + const parsed = toArDayjs(value); + return parsed.isValid() ? parsed.format('DD/MM/YYYY') : '—'; +} + +/** + * Formats a pure calendar date (no time-of-day meaning — a tournament's + * start date, a registration deadline, a birth date) as "16/08/2026". + * + * Unlike {@link formatDateAr}, this does NOT shift the value into Argentina + * time: a date-only field is submitted as UTC midnight of the intended day + * (`new Date("2026-10-10")` → `2026-10-10T00:00:00.000Z`) and stored as-is, + * so applying any timezone conversion on display — Argentina or otherwise — + * rolls it back to the previous day for any viewer west of UTC. Reading the + * value's UTC calendar-date components directly is the only correct way to + * round-trip a date-only value regardless of viewer timezone. + * Returns "—" for empty or unparseable input. + */ +export function formatCalendarDate(value?: Date | string | null): string { + if (!value) return '—'; + const parsed = dayjs.utc(value); + return parsed.isValid() ? parsed.format('DD/MM/YYYY') : '—'; +} + +/** + * Formats a UTC value as a short Argentina-time date and time, e.g. + * "16/08/2026 14:30". Returns "—" for empty or unparseable input. + */ +export function formatDateTimeAr(value?: Date | string | null): string { + if (!value) return '—'; + const parsed = toArDayjs(value); + return parsed.isValid() ? parsed.format('DD/MM/YYYY HH:mm') : '—'; +} + +/** + * Formats a UTC value as an Argentina-time clock time, e.g. "14:30". + * Returns "—" for empty or unparseable input. + */ +export function formatTimeAr(value?: Date | string | null): string { + if (!value) return '—'; + const parsed = toArDayjs(value); + return parsed.isValid() ? parsed.format('HH:mm') : '—'; +} + +/** + * Formats a UTC value as a long Spanish Argentina-time date, with no + * time-of-day, e.g. "lunes, 16 de agosto de 2026". Returns "—" for + * empty/invalid input. + */ +export function formatLongDateAr(value?: Date | string | null): string { + if (!value) return '—'; + const parsed = toArDayjs(value); + return parsed.isValid() ? parsed.locale('es').format('dddd, D [de] MMMM [de] YYYY') : '—'; +} + +/** + * Formats a UTC value as a long Spanish Argentina-time date and time, e.g. + * "lunes, 16 de agosto de 2026 • 14:30". Returns "—" for empty/invalid input. + */ +export function formatLongDateTimeAr(value?: Date | string | null): string { + if (!value) return '—'; + const parsed = toArDayjs(value); + return parsed.isValid() + ? parsed.locale('es').format('dddd, D [de] MMMM [de] YYYY • HH:mm') + : '—'; +} + +/** + * Argentina-time calendar-day key ("YYYY-MM-DD") for a UTC value, or + * "unknown" for empty/invalid input. Used to group items (e.g. fixtures) by + * their Argentina-time day so an instant near midnight lands on the day the + * user actually sees, not the UTC day. + */ +export function toArDayKey(value?: Date | string | null): string { + if (!value) return 'unknown'; + const parsed = toArDayjs(value); + return parsed.isValid() ? parsed.format('YYYY-MM-DD') : 'unknown'; +} + +/** + * Formats a "YYYY-MM-DD" day key (see toArDayKey) as a long, capitalized + * Spanish weekday + day + month label, e.g. "Jueves, 1 de enero". + */ +export function formatArDayLabel(dayKey: string): string { + const parsed = dayjs(dayKey); + if (!parsed.isValid()) return 'Fecha a confirmar'; + const label = parsed.locale('es').format('dddd, D [de] MMMM'); + return label.charAt(0).toUpperCase() + label.slice(1); +} + +/** + * Converts a UTC date string to a formatted Argentina-time date string in + * Spanish, e.g. "lunes, 16 de agosto de 2026 • 14:30". + * @param dateString - The date string in UTC format. + */ +export function formatMatchDateToString(dateString: string): string { + return formatLongDateTimeAr(dateString); +} + +/** + * Converts a UTC date string to a local Date object. + * @param dateString - The date string in UTC format. + * @returns A JavaScript Date object in the local timezone. + */ +export function parseUTCToLocalDate(dateString: string): Date { + if (!dateString) return new Date(NaN); + + return dayjs.utc(dateString).local().toDate(); +} + +/** + * Converts a Date object (assumed UTC) to a local Date object. + * @param date - The Date object in UTC. + * @returns A JavaScript Date object in the local timezone. + */ +export function convertToLocalDate(date: Date): Date { + if (!date) return new Date(NaN); + + return dayjs.utc(date).local().toDate(); +} + +/** + * Converts a Date to a string compatible with in local time. + * @param date - The Date object (UTC or local) + * @returns string in "YYYY-MM-DDTHH:mm" format + */ +export function formatDateTimeInput(date: Date): string { + if (!date) return ''; + return dayjs(date).local().format('YYYY-MM-DDTHH:mm'); +} + +/** + * Formats an ISO/UTC date string into the "YYYY-MM-DDTHH:mm" value an + * needs, or "" for empty/unparseable input. + * Used to preload a datetime-local field (e.g. a match's date when editing it + * or issuing a sanction from that match). + */ +export function toDatetimeLocalValue(iso?: string | null): string { + if (!iso) return ''; + const parsed = new Date(iso); + return Number.isNaN(parsed.getTime()) ? '' : formatDateTimeInput(parsed); +} + +/** + * Compares whether a deadline date is after the current date. + * @param {Date} deadline - The deadline to compare. + * @returns {boolean} - 'true' if the deadline has not yet passed, 'false' if it has already passed. + */ +export const isDeadlineInTheFuture = (deadline: Date): boolean => { + const now = new Date(); + return new Date(deadline) > now; +}; diff --git a/Club12-WebClient/src/modules/core/utils/geocoding.test.ts b/Club12-WebClient/src/modules/core/utils/geocoding.test.ts new file mode 100644 index 0000000..468a7ff --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/geocoding.test.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { geocodeAddress } from './geocoding'; + +const mockFetch = (response: Partial & { jsonBody?: unknown }) => { + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: response.ok ?? true, + json: () => Promise.resolve(response.jsonBody ?? []), + }) + ); +}; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('geocodeAddress', () => { + it('returns the first result\'s coordinates', async () => { + mockFetch({ jsonBody: [{ lat: '-34.6037', lon: '-58.3816' }] }); + + const result = await geocodeAddress('Av. Corrientes 1000, CABA'); + + expect(result).toEqual({ latitude: -34.6037, longitude: -58.3816 }); + }); + + it('returns null for an empty address without calling the network', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + + const result = await geocodeAddress(' '); + + expect(result).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('returns null when the search yields no results', async () => { + mockFetch({ jsonBody: [] }); + + const result = await geocodeAddress('an address that does not exist anywhere'); + + expect(result).toBeNull(); + }); + + it('returns null when the request fails', async () => { + mockFetch({ ok: false }); + + const result = await geocodeAddress('some address'); + + expect(result).toBeNull(); + }); + + it('returns null when the network throws', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down'))); + + const result = await geocodeAddress('some address'); + + expect(result).toBeNull(); + }); +}); diff --git a/Club12-WebClient/src/modules/core/utils/geocoding.ts b/Club12-WebClient/src/modules/core/utils/geocoding.ts new file mode 100644 index 0000000..5a43b2c --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/geocoding.ts @@ -0,0 +1,44 @@ +/** + * Looks up an address's coordinates via OpenStreetMap's free Nominatim + * search API (no API key required). Callers must debounce their own calls + * (e.g. on a pause in typing) rather than firing on every keystroke, per + * Nominatim's usage policy (https://operations.osmfoundation.org/policies/nominatim/). + * @param address The free-text address to look up. + * @returns The first match's coordinates, or null if nothing was found or the + * lookup failed. + */ +export const geocodeAddress = async ( + address: string +): Promise<{ latitude: number; longitude: number } | null> => { + const trimmed = address.trim(); + if (!trimmed) { + return null; + } + + try { + const url = `https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=${encodeURIComponent(trimmed)}`; + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + }); + + if (!response.ok) { + return null; + } + + const results: Array<{ lat: string; lon: string }> = await response.json(); + const [first] = results; + if (!first) { + return null; + } + + const latitude = Number(first.lat); + const longitude = Number(first.lon); + if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) { + return null; + } + + return { latitude, longitude }; + } catch { + return null; + } +}; diff --git a/Club12-WebClient/src/modules/core/utils/maintenanceBanner.test.ts b/Club12-WebClient/src/modules/core/utils/maintenanceBanner.test.ts new file mode 100644 index 0000000..6b508e5 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/maintenanceBanner.test.ts @@ -0,0 +1,112 @@ +import { AxiosError } from 'axios'; +import axios from 'axios'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { sendGet } from '@/modules/core/utils/axiosUtils'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; +import { + dismissMaintenanceBanner, + getMaintenanceBannerSnapshot, + subscribeMaintenanceBanner, +} from '@/modules/core/utils/maintenanceBanner'; + +vi.mock('axios', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + default: { + ...actual.default, + request: vi.fn(), + }, + }; +}); + +const build503Error = (): AxiosError => + ({ + isAxiosError: true, + name: 'AxiosError', + message: 'Request failed with status code 503', + config: { headers: {} }, + response: { + status: HttpStatus.ServiceUnavailable, + data: { message: 'La base de datos está en mantenimiento.' }, + statusText: 'Service Unavailable', + headers: {}, + config: { headers: {} }, + }, + toJSON: () => ({}), + }) as unknown as AxiosError; + +beforeEach(() => { + vi.clearAllMocks(); + dismissMaintenanceBanner(); +}); + +afterEach(() => { + dismissMaintenanceBanner(); +}); + +describe('maintenance banner — registered against the axiosUtils handler registry', () => { + it('starts inactive', () => { + expect(getMaintenanceBannerSnapshot()).toBe(false); + }); + + it('flips active when any request receives a 503, via onStatusCode(HttpStatus.ServiceUnavailable, ...)', async () => { + vi.mocked(axios.request).mockRejectedValueOnce(build503Error()); + + await expect(sendGet('backups')).rejects.toBeTruthy(); + + expect(getMaintenanceBannerSnapshot()).toBe(true); + }); + + it('notifies subscribers when the banner flips active', async () => { + vi.mocked(axios.request).mockRejectedValueOnce(build503Error()); + let notified = false; + const unsubscribe = subscribeMaintenanceBanner(() => { + notified = true; + }); + + await expect(sendGet('backups')).rejects.toBeTruthy(); + + expect(notified).toBe(true); + unsubscribe(); + }); + + it('does not flip the banner for an unrelated status code (e.g. 404)', async () => { + const notFoundError = { + isAxiosError: true, + name: 'AxiosError', + message: 'Request failed with status code 404', + config: { headers: {} }, + response: { + status: HttpStatus.NotFound, + data: {}, + statusText: 'Not Found', + headers: {}, + config: { headers: {} }, + }, + toJSON: () => ({}), + } as unknown as AxiosError; + vi.mocked(axios.request).mockRejectedValueOnce(notFoundError); + + await expect(sendGet('backups')).rejects.toBeTruthy(); + + expect(getMaintenanceBannerSnapshot()).toBe(false); + }); + + it('dismissMaintenanceBanner resets the banner to inactive and notifies subscribers', async () => { + vi.mocked(axios.request).mockRejectedValueOnce(build503Error()); + await expect(sendGet('backups')).rejects.toBeTruthy(); + expect(getMaintenanceBannerSnapshot()).toBe(true); + + let notified = false; + const unsubscribe = subscribeMaintenanceBanner(() => { + notified = true; + }); + + dismissMaintenanceBanner(); + + expect(getMaintenanceBannerSnapshot()).toBe(false); + expect(notified).toBe(true); + unsubscribe(); + }); +}); diff --git a/Club12-WebClient/src/modules/core/utils/maintenanceBanner.ts b/Club12-WebClient/src/modules/core/utils/maintenanceBanner.ts new file mode 100644 index 0000000..91d3819 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/maintenanceBanner.ts @@ -0,0 +1,57 @@ +import { onStatusCode } from '@/modules/core/utils/axiosUtils'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +/** + * Global "the database is in maintenance" banner state, flipped by any + * request that comes back `503 Service Unavailable` (the restore-in-progress + * gate from `MaintenanceModeMiddleware`). Deliberately a plain module-level + * subscribable store — not a React context — so it can be wired against + * `onStatusCode` at import time, the same already-supported extension point + * `onUnauthorized` uses, with no axios interceptor rewrite. + */ +type Listener = () => void; + +let isActive = false; +const listeners = new Set(); + +const notify = (): void => { + listeners.forEach(listener => listener()); +}; + +/** + * Marks the maintenance banner active and notifies every subscriber. + */ +export const activateMaintenanceBanner = (): void => { + isActive = true; + notify(); +}; + +/** + * Marks the maintenance banner inactive and notifies every subscriber. + * Used once maintenance mode is confirmed cleared (e.g. after a manual + * `DELETE /api/maintenance` escape hatch, or after the banner UI dismisses). + */ +export const dismissMaintenanceBanner = (): void => { + isActive = false; + notify(); +}; + +/** + * Current banner state, suitable as a `useSyncExternalStore` snapshot. + * @returns {boolean} Whether the maintenance banner is currently active. + */ +export const getMaintenanceBannerSnapshot = (): boolean => isActive; + +/** + * Subscribes to banner state changes. + * @param {Listener} listener - Called with no arguments whenever the banner flips. + * @returns {() => void} Unsubscribe function. + */ +export const subscribeMaintenanceBanner = (listener: Listener): (() => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +onStatusCode(HttpStatus.ServiceUnavailable, () => { + activateMaintenanceBanner(); +}); diff --git a/Club12-WebClient/src/modules/core/utils/pageMetadata.test.ts b/Club12-WebClient/src/modules/core/utils/pageMetadata.test.ts new file mode 100644 index 0000000..b94ebfe --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/pageMetadata.test.ts @@ -0,0 +1,124 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + DEFAULT_PAGE_METADATA, + resetPageMetadata, + setPageMetadata, + toAbsoluteUrl, +} from '@/modules/core/utils/pageMetadata'; + +const metaContent = (attribute: 'property' | 'name', key: string) => + document.head + .querySelector(`meta[${attribute}="${key}"]`) + ?.getAttribute('content'); + +const canonicalHref = () => + document.head + .querySelector('link[rel="canonical"]') + ?.getAttribute('href'); + +describe('toAbsoluteUrl', () => { + it('returns undefined for an empty path', () => { + expect(toAbsoluteUrl(undefined, 'https://club12.com')).toBeUndefined(); + expect(toAbsoluteUrl('', 'https://club12.com')).toBeUndefined(); + }); + + it('leaves an already-absolute URL untouched', () => { + expect(toAbsoluteUrl('https://cdn/x.png', 'https://club12.com')).toBe( + 'https://cdn/x.png' + ); + expect(toAbsoluteUrl('http://cdn/x.png', 'https://club12.com')).toBe( + 'http://cdn/x.png' + ); + }); + + it('joins a root-relative path onto the origin', () => { + expect(toAbsoluteUrl('/assets/logo.png', 'https://club12.com')).toBe( + 'https://club12.com/assets/logo.png' + ); + }); + + it('joins a bare path onto the origin with a separator', () => { + expect(toAbsoluteUrl('assets/logo.png', 'https://club12.com')).toBe( + 'https://club12.com/assets/logo.png' + ); + }); + + it('returns the relative path unchanged when there is no origin', () => { + expect(toAbsoluteUrl('/assets/logo.png', '')).toBe('/assets/logo.png'); + }); +}); + +describe('pageMetadata', () => { + afterEach(() => { + document.head.querySelectorAll('meta').forEach(meta => meta.remove()); + document.head + .querySelectorAll('link[rel="canonical"]') + .forEach(link => link.remove()); + document.title = ''; + }); + + it('writes Open Graph and Twitter tags for a post (HU-17)', () => { + setPageMetadata({ + title: 'Gran final', + description: 'Resumen del partido', + image: 'https://cdn.club12/photo.png', + url: 'https://club12/blog/gran-final', + type: 'article', + }); + + expect(document.title).toBe('Gran final · Club 12'); + expect(metaContent('property', 'og:title')).toBe('Gran final'); + expect(metaContent('property', 'og:description')).toBe( + 'Resumen del partido' + ); + expect(metaContent('property', 'og:image')).toBe( + 'https://cdn.club12/photo.png' + ); + expect(metaContent('property', 'og:url')).toBe( + 'https://club12/blog/gran-final' + ); + expect(metaContent('property', 'og:type')).toBe('article'); + expect(metaContent('name', 'twitter:card')).toBe('summary_large_image'); + expect(metaContent('name', 'twitter:title')).toBe('Gran final'); + expect(metaContent('name', 'twitter:image')).toBe( + 'https://cdn.club12/photo.png' + ); + }); + + it('falls back to a plain summary card when there is no image', () => { + setPageMetadata({ title: 'Sin imagen', description: 'Texto' }); + + expect(metaContent('name', 'twitter:card')).toBe('summary'); + expect(metaContent('property', 'og:image')).toBeUndefined(); + expect(metaContent('name', 'twitter:image')).toBeUndefined(); + }); + + it('reset restores the site defaults', () => { + setPageMetadata({ title: 'Gran final', description: 'x' }); + resetPageMetadata(); + + expect(document.title).toBe('Club 12'); + expect(metaContent('property', 'og:title')).toBe( + DEFAULT_PAGE_METADATA.title + ); + expect(metaContent('property', 'og:description')).toBe( + DEFAULT_PAGE_METADATA.description + ); + }); + + it('writes a canonical link defaulting to the current origin + path', () => { + setPageMetadata({ title: 'Campeones' }); + + const canonical = canonicalHref(); + expect(canonical).toBeDefined(); + expect(canonical).toBe( + `${window.location.origin}${window.location.pathname}` + ); + }); + + it('honours an explicit canonical url', () => { + setPageMetadata({ title: 'Post', url: 'https://club12.com/blog/x' }); + + expect(canonicalHref()).toBe('https://club12.com/blog/x'); + }); +}); diff --git a/Club12-WebClient/src/modules/core/utils/pageMetadata.ts b/Club12-WebClient/src/modules/core/utils/pageMetadata.ts new file mode 100644 index 0000000..226728f --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/pageMetadata.ts @@ -0,0 +1,182 @@ +import { useEffect } from 'react'; + +/** + * Per-page social/SEO metadata (HU-17). Because the app is a client-rendered + * SPA (no SSR), these tags are written into the document head at runtime. That + * covers on-page sharing widgets and crawlers that execute JavaScript; static + * scrapers that never run JS will still read the index.html defaults. + */ +export interface PageMetadata { + /** Document title / og:title / twitter:title. */ + title?: string; + /** Meta description / og:description / twitter:description. */ + description?: string; + /** Absolute image URL for og:image / twitter:image. */ + image?: string; + /** Canonical URL for og:url. Defaults to the current location. */ + url?: string; + /** og:type. Defaults to "website". */ + type?: string; +} + +const SITE_NAME = 'Club 12'; + +/** + * Default social-share image (SEO). A root-relative path lives in `public/`; + * it is resolved to an absolute URL against the current origin at runtime so + * scrapers get a fully-qualified `og:image`. + */ +export const DEFAULT_OG_IMAGE = '/assets/logo-club12.png'; + +/** The site-wide default metadata restored when a page unmounts (HU-17). */ +export const DEFAULT_PAGE_METADATA: PageMetadata = { + title: SITE_NAME, + description: + 'La liga de básquet amateur con más historia de la zona. Torneos, ' + + 'resultados y estadísticas de todas las divisiones en un solo lugar.', + type: 'website', + image: DEFAULT_OG_IMAGE, +}; + +/** + * Resolves a possibly-relative asset/URL path into an absolute URL against the + * given origin. Absolute inputs (http/https) pass through unchanged; empty + * inputs yield `undefined`; when there is no origin (SSR/tests) the relative + * path is returned as-is. Pure — safe to unit test. + */ +export const toAbsoluteUrl = ( + path: string | undefined, + origin: string +): string | undefined => { + if (!path) { + return undefined; + } + + if (/^https?:\/\//i.test(path)) { + return path; + } + + if (!origin) { + return path; + } + + return path.startsWith('/') ? `${origin}${path}` : `${origin}/${path}`; +}; + +const upsertCanonical = (href?: string): void => { + if (typeof document === 'undefined') { + return; + } + + const selector = 'link[rel="canonical"]'; + let element = document.head.querySelector(selector); + + if (!href) { + element?.remove(); + return; + } + + if (!element) { + element = document.createElement('link'); + element.setAttribute('rel', 'canonical'); + document.head.appendChild(element); + } + + element.setAttribute('href', href); +}; + +const upsertMeta = ( + attribute: 'property' | 'name', + key: string, + content?: string +): void => { + if (typeof document === 'undefined') { + return; + } + + const selector = `meta[${attribute}="${key}"]`; + let element = document.head.querySelector(selector); + + if (!content) { + element?.remove(); + return; + } + + if (!element) { + element = document.createElement('meta'); + element.setAttribute(attribute, key); + document.head.appendChild(element); + } + + element.setAttribute('content', content); +}; + +/** + * Writes the given Open Graph / Twitter Card metadata into the document head, + * merging over the site defaults (HU-17). Title and description always fall + * back to the defaults; image and url are only emitted when provided. + * + * @param metadata The page-specific overrides. + */ +export const setPageMetadata = (metadata: PageMetadata): void => { + if (typeof document === 'undefined') { + return; + } + + const title = metadata.title ?? DEFAULT_PAGE_METADATA.title; + const description = + metadata.description ?? DEFAULT_PAGE_METADATA.description; + const type = metadata.type ?? DEFAULT_PAGE_METADATA.type; + const origin = + typeof window !== 'undefined' ? window.location.origin : ''; + const pathname = + typeof window !== 'undefined' ? window.location.pathname : ''; + // Canonical (and og:url) omit query/hash so tab/filter permutations of a page + // collapse to one indexable URL. + const canonical = + metadata.url ?? (origin ? `${origin}${pathname}` : undefined); + const image = toAbsoluteUrl(metadata.image, origin); + + const documentTitle = + title && title !== SITE_NAME ? `${title} · ${SITE_NAME}` : SITE_NAME; + document.title = documentTitle; + + upsertMeta('name', 'description', description); + + upsertMeta('property', 'og:site_name', SITE_NAME); + upsertMeta('property', 'og:title', title); + upsertMeta('property', 'og:description', description); + upsertMeta('property', 'og:type', type); + upsertMeta('property', 'og:url', canonical); + upsertMeta('property', 'og:image', image); + + upsertMeta('name', 'twitter:card', image ? 'summary_large_image' : 'summary'); + upsertMeta('name', 'twitter:title', title); + upsertMeta('name', 'twitter:description', description); + upsertMeta('name', 'twitter:image', image); + + upsertCanonical(canonical); +}; + +/** Restores the site-wide default metadata (HU-17). */ +export const resetPageMetadata = (): void => { + setPageMetadata(DEFAULT_PAGE_METADATA); +}; + +/** + * React hook that applies per-page metadata on mount/update and restores the + * site defaults on unmount (HU-17). Pass a stable/serialisable metadata object. + * + * @param metadata The page-specific metadata to apply. + */ +export const usePageMetadata = (metadata: PageMetadata): void => { + const { title, description, image, url, type } = metadata; + + useEffect(() => { + setPageMetadata({ title, description, image, url, type }); + + return () => { + resetPageMetadata(); + }; + }, [title, description, image, url, type]); +}; diff --git a/Club12-WebClient/src/modules/core/utils/printStyles.ts b/Club12-WebClient/src/modules/core/utils/printStyles.ts new file mode 100644 index 0000000..f954068 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/printStyles.ts @@ -0,0 +1,31 @@ +/** + * `@media print` isolation: hide every element on the page except the + * subtree tagged `[data-print="sheet"]`, and force that subtree visible even + * though it is `display:none` on screen. This hides all app chrome + * (nav/tabs/buttons) without needing to tag them individually. Shared by + * every print-only sheet (standings, goleadores, …) so they all get the + * exact same isolation behavior from one place. Import and render via + * `` in each sheet component — + * only the currently-mounted sheet's `[data-print="sheet"]` node matters, so + * it is safe for more than one sheet component to inject this at once. + */ +export const printMediaStyles = { + '@media print': { + 'body *': { visibility: 'hidden' }, + '[data-print="sheet"], [data-print="sheet"] *': { visibility: 'visible' }, + '[data-print="sheet"]': { + display: 'block !important', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + }, + '[data-print="hide"]': { display: 'none !important' }, + thead: { display: 'table-header-group' }, + tr: { breakInside: 'avoid' }, + '*': { + printColorAdjust: 'exact', + WebkitPrintColorAdjust: 'exact', + }, + }, +}; diff --git a/Club12-WebClient/src/modules/core/utils/problemDetails.ts b/Club12-WebClient/src/modules/core/utils/problemDetails.ts new file mode 100644 index 0000000..2abeee8 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/problemDetails.ts @@ -0,0 +1,28 @@ +import { AxiosError } from 'axios'; + +/** + * Discriminated outcome of a mutation that can fail with a user-facing reason + * (e.g. a 409 Conflict the backend returns with a Spanish message). Callers + * surface {@link errorMessage} inline instead of routing the failure through + * the global error handler. + */ +export type MutationResult = + | { success: true } + | { success: false; errorMessage: string }; + +/** + * Reads the ProblemDetails `detail` string from an Axios error response, when + * present. The backend returns the raw business message there for 4xx errors. + * + * @param error The error thrown by a request. + * @returns The `detail` message, or undefined when it is not an Axios error + * with a string detail. + */ +export const extractProblemDetail = (error: unknown): string | undefined => { + if (!(error instanceof AxiosError)) { + return undefined; + } + + const data = error.response?.data as { detail?: unknown } | undefined; + return typeof data?.detail === 'string' ? data.detail : undefined; +}; diff --git a/Club12-WebClient/src/modules/core/utils/requestActivity.test.ts b/Club12-WebClient/src/modules/core/utils/requestActivity.test.ts new file mode 100644 index 0000000..fbbce90 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/requestActivity.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest'; +import { + beginRequest, + clearBlockingMessage, + endRequest, + getActiveRequestCount, + getBlockingMessage, + runWithBlockingMessage, + setBlockingMessage, + subscribeToRequestActivity, +} from './requestActivity'; + +describe('requestActivity', () => { + it('tracks nested begin/end calls and notifies subscribers with the running count', () => { + const seen: number[] = []; + const unsubscribe = subscribeToRequestActivity(count => seen.push(count)); + + beginRequest(); + beginRequest(); + endRequest(); + endRequest(); + + expect(seen).toEqual([1, 2, 1, 0]); + expect(getActiveRequestCount()).toBe(0); + + unsubscribe(); + }); + + it('never goes negative when endRequest is called without a matching begin', () => { + endRequest(); + expect(getActiveRequestCount()).toBe(0); + }); + + it('stops notifying a listener after it unsubscribes', () => { + const seen: number[] = []; + const unsubscribe = subscribeToRequestActivity(count => seen.push(count)); + unsubscribe(); + + beginRequest(); + endRequest(); + + expect(seen).toEqual([]); + }); +}); + +describe('requestActivity — blocking message', () => { + it('has no message until one is set, and the newest set message wins', () => { + expect(getBlockingMessage()).toBeNull(); + + const first = setBlockingMessage('Restaurando…'); + expect(getBlockingMessage()).toBe('Restaurando…'); + + const second = setBlockingMessage('Generando…'); + expect(getBlockingMessage()).toBe('Generando…'); + + clearBlockingMessage(second); + expect(getBlockingMessage()).toBe('Restaurando…'); + + clearBlockingMessage(first); + expect(getBlockingMessage()).toBeNull(); + }); + + it('tolerates clearing messages out of order', () => { + const a = setBlockingMessage('A'); + const b = setBlockingMessage('B'); + + clearBlockingMessage(a); + expect(getBlockingMessage()).toBe('B'); + + clearBlockingMessage(b); + expect(getBlockingMessage()).toBeNull(); + }); + + it('notifies subscribers when the message changes', () => { + let calls = 0; + const unsubscribe = subscribeToRequestActivity(() => { + calls += 1; + }); + + const id = setBlockingMessage('X'); + clearBlockingMessage(id); + + expect(calls).toBe(2); + unsubscribe(); + }); + + it('runWithBlockingMessage shows the message around the operation and clears it even on throw', async () => { + await runWithBlockingMessage('Trabajando…', async () => { + expect(getBlockingMessage()).toBe('Trabajando…'); + }); + expect(getBlockingMessage()).toBeNull(); + + await expect( + runWithBlockingMessage('Fallando…', async () => { + throw new Error('boom'); + }) + ).rejects.toThrow('boom'); + expect(getBlockingMessage()).toBeNull(); + }); +}); diff --git a/Club12-WebClient/src/modules/core/utils/requestActivity.ts b/Club12-WebClient/src/modules/core/utils/requestActivity.ts new file mode 100644 index 0000000..a5f69bc --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/requestActivity.ts @@ -0,0 +1,87 @@ +type Listener = (activeCount: number) => void; + +let activeCount = 0; +const listeners = new Set(); + +// A LIFO stack of contextual overlay messages. Each is pushed with a unique id +// so it can be popped out of order (nested/overlapping operations); the newest +// one wins as the visible message. Empty stack => no message, just the spinner. +const messageStack: { id: number; text: string }[] = []; +let nextMessageId = 1; + +const notify = (): void => { + listeners.forEach(listener => listener(activeCount)); +}; + +/** + * A tiny external store tracking how many mutating HTTP requests (POST/PUT/ + * DELETE — loading/uploading/saving something that can take a moment) are in + * flight right now. `axiosUtils.sendRequest` is the single choke point every + * request goes through, so it increments/decrements this instead of each + * screen tracking its own `submitting` flag — `GlobalLoadingOverlay` + * subscribes to it once, at the app root, so ANY save/upload blocks the + * whole screen with a spinner without every call site wiring it manually. + * GET requests are intentionally excluded: page data already has its own + * skeleton-loading convention, and blocking the screen on every background + * fetch would be a jarring regression (see the "no blocking modals for GETs" + * rule the public pages already follow). + */ +export const beginRequest = (): void => { + activeCount += 1; + notify(); +}; + +export const endRequest = (): void => { + activeCount = Math.max(0, activeCount - 1); + notify(); +}; + +export const getActiveRequestCount = (): number => activeCount; + +/** + * Sets a contextual message on the global blocking overlay for the duration of + * a long operation (e.g. "Restaurando la base de datos. No cierres esta + * página…"). Returns an id to pass to {@link clearBlockingMessage}. Prefer + * {@link runWithBlockingMessage}, which pairs the two automatically. + */ +export const setBlockingMessage = (text: string): number => { + const id = nextMessageId++; + messageStack.push({ id, text }); + notify(); + return id; +}; + +export const clearBlockingMessage = (id: number): void => { + const index = messageStack.findIndex(entry => entry.id === id); + if (index !== -1) { + messageStack.splice(index, 1); + notify(); + } +}; + +export const getBlockingMessage = (): string | null => + messageStack.length > 0 ? messageStack[messageStack.length - 1].text : null; + +/** + * Runs `operation` with `message` shown on the global blocking overlay, always + * clearing it afterwards (success or throw). The overlay is already visible for + * any mutating request in flight; this only adds the contextual text. + */ +export const runWithBlockingMessage = async ( + message: string, + operation: () => Promise +): Promise => { + const id = setBlockingMessage(message); + try { + return await operation(); + } finally { + clearBlockingMessage(id); + } +}; + +export const subscribeToRequestActivity = (listener: Listener): (() => void) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +}; diff --git a/Club12-WebClient/src/modules/core/utils/synchronizeStates.ts b/Club12-WebClient/src/modules/core/utils/synchronizeStates.ts new file mode 100644 index 0000000..057f93b --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/synchronizeStates.ts @@ -0,0 +1,17 @@ +import { GUID } from '@/modules/core/types/types'; + +export function upsertListById( + list: T[] | null | undefined, + item: T +): T[] { + const safeList = list ?? []; + + const index = safeList.findIndex(x => x.id === item.id); + if (index !== -1) { + const newList = [...safeList]; + newList[index] = item; + return newList; + } else { + return [...safeList, item]; + } +} diff --git a/Club12-WebClient/src/modules/core/utils/translateStageType.ts b/Club12-WebClient/src/modules/core/utils/translateStageType.ts new file mode 100644 index 0000000..5315d3e --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/translateStageType.ts @@ -0,0 +1,20 @@ +import { StageType } from '@/modules/stage/type/stage'; + +/** + * Spanish translations for each StageType value. + * Using `satisfies` enforces exhaustiveness at compile time. + */ +const STAGE_TYPE_ES = { + [StageType.Group]: 'Fase de grupos', + [StageType.RoundOf16]: 'Octavos de final', + [StageType.QuarterFinal]: 'Cuartos de final', + [StageType.SemiFinal]: 'Semifinal', + [StageType.ThirdPlace]: 'Tercer puesto', + [StageType.Final]: 'Final', +} satisfies Record; + +/** + * Translates a StageType enum value into its Spanish equivalent. + */ +export const translateStageType = (stageType: StageType): string => + STAGE_TYPE_ES[stageType]; diff --git a/Club12-WebClient/src/modules/core/utils/validators.test.ts b/Club12-WebClient/src/modules/core/utils/validators.test.ts new file mode 100644 index 0000000..fcae785 --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/validators.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest'; +import { + formatArgentinePhone, + formatDocumentNumber, + isAtLeastMinimumPlayerAge, + isValidDocumentNumber, + isValidEmail, + isValidPhone, +} from './validators'; + +describe('isValidEmail', () => { + it.each([ + 'user@example.com', + 'jugador.12@club.com.ar', + 'a@b.co', + ' spaced@example.com ', + ])('accepts a valid email: %s', email => { + expect(isValidEmail(email)).toBe(true); + }); + + it.each([ + '', + 'plainaddress', + 'missing@domain', + '@no-local.com', + 'no-at-sign.com', + 'spaces in@email.com', + 'double@@example.com', + ])('rejects an invalid email: %s', email => { + expect(isValidEmail(email)).toBe(false); + }); +}); + +describe('isValidPhone', () => { + it.each(['1123456789', '11 2345-6789', '(11) 2345 6789', '3431234567'])( + 'accepts a 10-digit national number: %s', + phone => { + expect(isValidPhone(phone)).toBe(true); + } + ); + + it.each([ + ['', 'empty'], + ['123', 'too few digits'], + ['1234567', '7 digits, below the 10-digit national length'], + ['01123456789', '11 digits — the 0 long-distance prefix is not accepted'], + ['91123456789', '11 digits — the 9 mobile marker is not accepted'], + ['+54 11 2345-6789', '12 digits — the +54 country code is not accepted'], + ['+54 9 11 2345-6789', '13 digits — +54 9 is not accepted'], + ['abc1234567', 'letters not allowed'], + ['11 2345 6789 ext.4', 'letters not allowed'], + ])('rejects an invalid phone: %s (%s)', phone => { + expect(isValidPhone(phone)).toBe(false); + }); + + it('counts only digits, ignoring separators', () => { + expect(isValidPhone('(11) 1234-5678')).toBe(true); + expect(isValidPhone('1-2-3-4-5-6-7')).toBe(false); + }); +}); + +describe('isValidDocumentNumber', () => { + it.each(['30111222', '1234567', '999999999999999'])( + 'accepts a digits-only document number: %s', + value => { + expect(isValidDocumentNumber(value)).toBe(true); + } + ); + + it.each([ + '', // empty + 'd23', // letters + '12345', // too short (5 digits) + '1234567890123456', // too long (16 digits) + '30.111.222', // formatted with dots — reject, not accept-and-strip + ])('rejects a non-digits-only document number: %s', value => { + expect(isValidDocumentNumber(value)).toBe(false); + }); +}); + +describe('formatArgentinePhone', () => { + it('formats a bare 10-digit local number in the national shape', () => { + expect(formatArgentinePhone('3435551234')).toBe('343 555-1234'); + }); + + it('strips separators from an already-formatted number', () => { + expect(formatArgentinePhone('343 555-1234')).toBe('343 555-1234'); + expect(formatArgentinePhone('(343) 555-1234')).toBe('343 555-1234'); + }); + + it('returns a number of unexpected length unchanged', () => { + expect(formatArgentinePhone('123')).toBe('123'); + expect(formatArgentinePhone('123456789012')).toBe('123456789012'); + }); +}); + +describe('formatDocumentNumber', () => { + it('adds dot thousands-separators to a valid document number', () => { + expect(formatDocumentNumber('38742615')).toBe('38.742.615'); + expect(formatDocumentNumber('412281692')).toBe('412.281.692'); + }); + + it('returns non-numeric input unchanged instead of mangling it', () => { + expect(formatDocumentNumber('d23')).toBe('d23'); + }); +}); + +describe('isAtLeastMinimumPlayerAge', () => { + // Builds "YYYY-MM-DD" from LOCAL date parts (not toISOString, which + // converts to UTC and can shift the calendar date near local midnight + // in timezones behind UTC, e.g. Argentina). + const isoDateYearsAgo = (years: number): string => { + const date = new Date(); + date.setFullYear(date.getFullYear() - years); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; + + it('accepts someone older than the minimum age', () => { + expect(isAtLeastMinimumPlayerAge(isoDateYearsAgo(20))).toBe(true); + }); + + it('accepts someone exactly at the minimum age', () => { + expect(isAtLeastMinimumPlayerAge(isoDateYearsAgo(15))).toBe(true); + }); + + it('rejects someone younger than the minimum age', () => { + expect(isAtLeastMinimumPlayerAge(isoDateYearsAgo(10))).toBe(false); + }); + + it('rejects an unparsable date', () => { + expect(isAtLeastMinimumPlayerAge('not-a-date')).toBe(false); + }); +}); diff --git a/Club12-WebClient/src/modules/core/utils/validators.ts b/Club12-WebClient/src/modules/core/utils/validators.ts new file mode 100644 index 0000000..112a60a --- /dev/null +++ b/Club12-WebClient/src/modules/core/utils/validators.ts @@ -0,0 +1,143 @@ +/** + * Shared email / phone validators reused by every form that collects contact + * data (user create/invite/edit, player create/edit, password recovery). + * + * The rules intentionally mirror the backend DataAnnotations in + * `Application.Utils.Constants.Validation.ValidationPatterns`, so the client and + * the server accept exactly the same values and the user never gets a 400 for + * input the form said was fine. + */ + +/** Standard, permissive email shape: local@domain.tld with no whitespace. */ +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +/** Characters allowed in a plausible phone number. */ +const PHONE_ALLOWED_CHARS_REGEX = /^[+\d\s()-]+$/; + +/** A player's DNI/document number: digits only, 6 to 15 of them. */ +const DOCUMENT_NUMBER_REGEX = /^\d{6,15}$/; + +/** + * ASP.NET Core Identity's default `IdentityOptions.User.AllowedUserNameCharacters` + * (letters, digits, and `-._@+`) — mirrored here so an invalid username (most + * commonly one with spaces, since the field's Spanish label "Nombre de + * usuario" reads like it could take a full name) is caught client-side with a + * Spanish message, instead of round-tripping to the backend and surfacing its + * raw English Identity error ("Username '...' is invalid, can only contain + * letters or digits.") straight to the admin. + */ +const USERNAME_REGEX = /^[a-zA-Z0-9\-._@+]+$/; + +/** Minimum age (years) a player must be, mirroring the backend's [MinimumAge(15)]. */ +const PLAYER_MINIMUM_AGE = 15; + +/** True when `value` is a syntactically valid email address. */ +export function isValidEmail(value: string): boolean { + return EMAIL_REGEX.test(value.trim()); +} + +/** + * True when `value` is a plausible Argentine phone number: only digits, + * spaces, `+`, `-`, and parentheses, and exactly 10 digits — the national + * format (area code + local number) used for calls placed from inside the + * country, with no leading `0` trunk prefix, no `15`, no `+54` country code + * and no `9` mobile marker (those only apply to international dialing, + * which this app — a local league — never needs). + */ +export function isValidPhone(value: string): boolean { + const trimmed = value.trim(); + + if (!PHONE_ALLOWED_CHARS_REGEX.test(trimmed)) { + return false; + } + + const digits = trimmed.replace(/\D/g, ''); + return digits.length === 10; +} + +/** True when `value` is a plausible DNI/document number: 6 to 15 digits only. */ +export function isValidDocumentNumber(value: string): boolean { + return DOCUMENT_NUMBER_REGEX.test(value.trim()); +} + +/** + * True when `value` is a valid username: letters, digits, and `-._@+` only + * (no spaces), matching the backend's actual accepted character set. + */ +export function isValidUsername(value: string): boolean { + return USERNAME_REGEX.test(value.trim()); +} + +/** + * True when `birthDate` (an `` value, "YYYY-MM-DD") puts + * the person at least {@link PLAYER_MINIMUM_AGE} years old today. Parses the + * Y/M/D components explicitly and builds a LOCAL date rather than relying on + * `new Date("YYYY-MM-DD")` — that form is UTC-midnight per spec, which would + * silently shift the effective date by a day in any timezone behind UTC (all + * of Argentina), misjudging someone born exactly on the cutoff date. An + * unparsable value is treated as invalid — the caller already requires the + * field, so an empty/malformed string should never reach here as "valid". + */ +export function isAtLeastMinimumPlayerAge(birthDate: string): boolean { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(birthDate.trim()); + if (!match) { + return false; + } + + const [, year, month, day] = match; + const parsed = new Date(Number(year), Number(month) - 1, Number(day)); + if (Number.isNaN(parsed.getTime())) { + return false; + } + + const cutoff = new Date(); + cutoff.setHours(0, 0, 0, 0); + cutoff.setFullYear(cutoff.getFullYear() - PLAYER_MINIMUM_AGE); + + return parsed.getTime() <= cutoff.getTime(); +} + +/** + * Formats a phone number for display in the Argentine national shape, e.g. + * "3435551234" → "343 555-1234" — no "+54" country code and no "9" mobile + * marker, since this app is only ever dialed from inside the country. Only + * a 10-digit local number (area code + line, the shape every phone in this + * app is stored as) can be confidently split into area/exchange/line + * without an area-code length table, so anything else is returned + * unchanged rather than mangled. + */ +export function formatArgentinePhone(value: string): string { + const digits = value.replace(/\D/g, ''); + + if (digits.length !== 10) { + return value; + } + + const area = digits.slice(0, 3); + const exchange = digits.slice(3, 6); + const line = digits.slice(6); + return `${area} ${exchange}-${line}`; +} + +/** + * Formats a DNI/document number with dot thousands-separators for display + * (e.g. "38742615" → "38.742.615", matching the printed-DNI convention). + * Non-numeric input (legacy/test data) is returned unchanged rather than + * mangled. + */ +export function formatDocumentNumber(value: string): string { + if (!isValidDocumentNumber(value)) { + return value; + } + + return Number(value).toLocaleString('es-AR'); +} + +/** Spanish (voseo) helper/error messages shown under the fields. */ +export const VALIDATION_MESSAGES = { + email: 'Ingresá un email válido', + phone: 'Ingresá un teléfono válido', + documentNumber: 'El documento debe tener solo números', + minimumPlayerAge: `El jugador debe tener al menos ${PLAYER_MINIMUM_AGE} años`, + username: 'El nombre de usuario no puede contener espacios ni símbolos (solo letras, números y - . _ @ +)', +} as const; diff --git a/Club12-WebClient/src/modules/dataMaintenance/service/dataMaintenance.service.ts b/Club12-WebClient/src/modules/dataMaintenance/service/dataMaintenance.service.ts new file mode 100644 index 0000000..d4ffcf0 --- /dev/null +++ b/Club12-WebClient/src/modules/dataMaintenance/service/dataMaintenance.service.ts @@ -0,0 +1,28 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { sendPost } from '@/modules/core/utils/axiosUtils'; +import { + IDataSeedResult, + IDataWipeResult, +} from '@/modules/dataMaintenance/type/dataMaintenance'; + +/** + * Admin-only tools for resetting tournament-domain data to a clean, + * realistic sample state. + */ +export const dataMaintenanceService = { + /** + * Deletes every tournament-domain row. Identity is untouched. + * @returns {Promise>} Row counts removed. + */ + wipeSampleData: async (): Promise> => + await sendPost(`${routes.dataMaintenance}/wipe`), + + /** + * Seeds 2 complete sample tournaments. Rejects with a 409 response if + * the database already has tournament data. + * @returns {Promise>} Row counts created. + */ + seedSampleData: async (): Promise> => + await sendPost(`${routes.dataMaintenance}/seed`), +}; diff --git a/Club12-WebClient/src/modules/dataMaintenance/type/dataMaintenance.d.ts b/Club12-WebClient/src/modules/dataMaintenance/type/dataMaintenance.d.ts new file mode 100644 index 0000000..32257b2 --- /dev/null +++ b/Club12-WebClient/src/modules/dataMaintenance/type/dataMaintenance.d.ts @@ -0,0 +1,26 @@ +export interface IDataWipeResult { + tournaments: number; + divisions: number; + teams: number; + players: number; + matches: number; + matchSeries: number; + playerSanctions: number; + playerStatistics: number; + scorers: number; + stageTeamMatches: number; + playerTeamRegistrations: number; + stages: number; + venues: number; + blogPosts: number; +} + +export interface IDataSeedResult { + tournaments: number; + divisions: number; + teams: number; + players: number; + matches: number; + playerSanctions: number; + blogPosts: number; +} diff --git a/Club12-WebClient/src/modules/division/context/division.context.test.tsx b/Club12-WebClient/src/modules/division/context/division.context.test.tsx new file mode 100644 index 0000000..1d5ec09 --- /dev/null +++ b/Club12-WebClient/src/modules/division/context/division.context.test.tsx @@ -0,0 +1,83 @@ +import { act, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import Swal from 'sweetalert2'; +import { ErrorProvider } from '@/modules/error/context/error.context'; +import { DivisionProvider } from '@/modules/division/context/division.context'; +import { useDivision } from '@/modules/division/hook/division.hook'; +import { divisionService } from '@/modules/division/service/division.service'; +import type { GUID } from '@/modules/core/types/types'; + +vi.mock('@/modules/division/service/division.service'); +vi.mock('sweetalert2', () => ({ + default: { + fire: vi.fn(), + getContainer: vi.fn().mockReturnValue(null), + }, +})); + +const mockedPutDivisionById = vi.mocked(divisionService.putDivisionById); +const mockedDeleteDivisionsById = vi.mocked(divisionService.deleteDivisionsById); +const mockedSwalFire = vi.mocked(Swal.fire); + +const DIVISION_ID = '55555555-5555-5555-5555-555555555555' as GUID; + +const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('DivisionProvider — no duplicate success toast', () => { + /** + * divisionEditPage.tsx / divisionsPage.tsx already show their own + * confirmation for these actions. The context used to ALSO fire a toast, so + * the user saw two modals with the same message for one action. + */ + it('does not fire its own toast after putDivisionById succeeds (200)', async () => { + mockedPutDivisionById.mockResolvedValueOnce({ + status: 200, + data: { id: DIVISION_ID, name: 'Zona B' }, + } as never); + + const { result } = renderHook(() => useDivision(), { wrapper }); + await act(async () => { + await result.current.putDivisionById(DIVISION_ID, { + name: 'Zona B', + } as never); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('does not fire its own toast after putDivisionById succeeds (204)', async () => { + mockedPutDivisionById.mockResolvedValueOnce({ status: 204 } as never); + + const { result } = renderHook(() => useDivision(), { wrapper }); + await act(async () => { + await result.current.putDivisionById(DIVISION_ID, { + name: 'Zona B', + } as never); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('does not fire its own toast after deleteDivisionsById succeeds', async () => { + mockedDeleteDivisionsById.mockResolvedValueOnce({ status: 204 } as never); + + const { result } = renderHook(() => useDivision(), { wrapper }); + await act(async () => { + await result.current.deleteDivisionsById(DIVISION_ID); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); +}); diff --git a/Club12-WebClient/src/modules/division/context/division.context.tsx b/Club12-WebClient/src/modules/division/context/division.context.tsx new file mode 100644 index 0000000..3566471 --- /dev/null +++ b/Club12-WebClient/src/modules/division/context/division.context.tsx @@ -0,0 +1,410 @@ +import { AxiosResponse } from 'axios'; +import { + createContext, + ReactNode, + useEffect, + useState, + useCallback, + useMemo, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + FetchOptions, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { useError } from '@/modules/error/hooks/error.hock'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { divisionService } from '@/modules/division/service/division.service'; +import { + AddDivisionRequest, + DivisionFiltered, + IDivisionResponse, + IDivisionContextProps, + IPutDivisionRequest, +} from '@/modules/division/type/division'; +import { ITeamResponse } from '@/modules/team/type/team.d'; +import { upsertListById } from '@/modules/core/utils/synchronizeStates'; +import { divisionKeys } from '@/modules/division/queryKeys'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +export const DivisionContext = createContext( + undefined +); + +export const DivisionProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [division, setDivision] = useState(null); + const [divisions, setDivisions] = useState(null); + + const { setMessage } = useError(); + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addDivisionMutation = useMutation({ + mutationFn: divisionService.addDivision, + }); + + const generateFixtureMutation = useMutation({ + mutationFn: divisionService.generateFixtureByDivisionId, + }); + + const putDivisionMutation = useMutation({ + mutationFn: ({ + id, + divisionRequest, + }: { + id: GUID; + divisionRequest: IPutDivisionRequest; + }) => divisionService.putDivisionById(id, divisionRequest), + }); + + const deleteDivisionMutation = useMutation({ + mutationFn: divisionService.deleteDivisionsById, + }); + + const enrollTeamsMutation = useMutation({ + mutationFn: ({ + divisionId, + teamIds, + }: { + divisionId: GUID; + teamIds: GUID[]; + }) => divisionService.enrollTeams(divisionId, teamIds), + }); + + const unenrollTeamsMutation = useMutation({ + mutationFn: ({ + divisionId, + teamIds, + }: { + divisionId: GUID; + teamIds: GUID[]; + }) => divisionService.unenrollTeams(divisionId, teamIds), + }); + + const autoDistributeMutation = useMutation({ + mutationFn: divisionService.autoDistribute, + }); + + const rebuildSubGroupsMutation = useMutation({ + mutationFn: ({ + divisionId, + subGroupCount, + }: { + divisionId: GUID; + subGroupCount: number; + }) => divisionService.rebuildSubGroups(divisionId, subGroupCount), + }); + + const reassignTeamToSubGroupMutation = useMutation({ + mutationFn: ({ + divisionId, + teamId, + fromStageId, + toStageId, + }: { + divisionId: GUID; + teamId: GUID; + fromStageId: GUID; + toStageId: GUID; + }) => + divisionService.reassignTeamToSubGroup( + divisionId, + teamId, + fromStageId, + toStageId + ), + }); + + useEffect(() => { + if (!division) return; + setDivisions(prev => upsertListById(prev, division)); + }, [division]); + + const addDivision = useCallback( + async ( + divisionRequest: AddDivisionRequest + ): Promise => { + try { + const res: AxiosResponse = + await addDivisionMutation.mutateAsync(divisionRequest); + if (res && res.data) { + setDivision(res.data); + queryClient.setQueryData(divisionKeys.byId(res.data.id), res); + setMessage(res.status, ['Division creada exitosamente']); + await queryClient.invalidateQueries({ + queryKey: divisionKeys.list(), + }); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addDivisionMutation, queryClient, setMessage, handleUnknownError] + ); + + const generateFixtureByDivisionId = useCallback( + async (id: GUID): Promise => { + try { + await generateFixtureMutation.mutateAsync(id); + setMessage(HttpStatus.Ok, ['Fixture generado exitosamente']); + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [generateFixtureMutation, setMessage, handleUnknownError] + ); + + const putDivisionById = useCallback( + async ( + id: GUID, + divisionRequest: IPutDivisionRequest + ): Promise => { + try { + const res: AxiosResponse = + await putDivisionMutation.mutateAsync({ id, divisionRequest }); + + // Success feedback belongs to the calling page (divisionEditPage.tsx + // shows its own confirmation) — a toast here too means two modals. + if (res && res.status === HttpStatus.NoContent) { + setDivision(prev => { + if (!prev || prev.id !== id) return prev; + + return { + ...prev, + name: divisionRequest.name, + }; + }); + await queryClient.invalidateQueries({ + queryKey: divisionKeys.list(), + }); + return true; + } else if (res && res.data) { + setDivision(res.data); + queryClient.setQueryData(divisionKeys.byId(id), res); + await queryClient.invalidateQueries({ + queryKey: divisionKeys.list(), + }); + return true; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putDivisionMutation, setDivision, queryClient, handleUnknownError] + ); + + const getDivisionsById = useCallback( + async (idOrSlug: string): Promise => { + try { + // Always fetch the full `/detail` projection. The cached list version + // (from getDivisionsByFilters) is a lighter shape without positions, + // group standings or qualificationRanges, so short-circuiting to it + // left the admin detail view's standings uncoloured (HU-45) — unlike + // the public panel, which calls the service directly and always hits + // `/detail`. + const res: AxiosResponse = + await queryClient.fetchQuery({ + queryKey: divisionKeys.byId(idOrSlug), + queryFn: async () => + await divisionService.getDivisionsById(idOrSlug), + }); + + if (res && res.data) { + setDivision(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [setDivision, queryClient, handleUnknownError] + ); + + const getDivisionsByFilters = useCallback( + async ( + filter: DivisionFiltered, + options?: FetchOptions + ): Promise | void> => { + try { + const res = await queryClient.fetchQuery({ + queryKey: divisionKeys.list(filter), + queryFn: async () => + await divisionService.getDivisionsByFilters(filter), + }); + + if (res?.data?.items) { + setDivisions(res.data.items); + return res.data; + } + } catch (error: unknown) { + if (!options?.silent) handleUnknownError(error); + } + }, + [setDivisions, queryClient, handleUnknownError] + ); + + const deleteDivisionsById = useCallback( + async (id: GUID): Promise => { + try { + await deleteDivisionMutation.mutateAsync(id); + setDivision(null); + setDivisions(prev => (prev ? prev.filter(e => e.id !== id) : null)); + queryClient.removeQueries({ queryKey: divisionKeys.byId(id) }); + await queryClient.invalidateQueries({ queryKey: divisionKeys.list() }); + // Success feedback belongs to the calling page (divisionsPage.tsx shows + // its own "¡Eliminada!" confirmation) — a toast here too means two modals. + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [deleteDivisionMutation, queryClient, handleUnknownError] + ); + + const getRoster = useCallback( + async (divisionId: GUID): Promise => { + try { + const res = await queryClient.fetchQuery({ + queryKey: divisionKeys.roster(divisionId), + queryFn: async () => await divisionService.getRoster(divisionId), + }); + + if (res?.data) { + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const enrollTeams = useCallback( + async (divisionId: GUID, teamIds: GUID[]): Promise => { + try { + await enrollTeamsMutation.mutateAsync({ divisionId, teamIds }); + await queryClient.invalidateQueries({ + queryKey: divisionKeys.roster(divisionId), + }); + return true; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [enrollTeamsMutation, queryClient, handleUnknownError] + ); + + const unenrollTeams = useCallback( + async (divisionId: GUID, teamIds: GUID[]): Promise => { + try { + await unenrollTeamsMutation.mutateAsync({ divisionId, teamIds }); + await queryClient.invalidateQueries({ + queryKey: divisionKeys.roster(divisionId), + }); + return true; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [unenrollTeamsMutation, queryClient, handleUnknownError] + ); + + const autoDistribute = useCallback( + async (divisionId: GUID): Promise => { + try { + await autoDistributeMutation.mutateAsync(divisionId); + return true; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [autoDistributeMutation, handleUnknownError] + ); + + const rebuildSubGroups = useCallback( + async (divisionId: GUID, subGroupCount: number): Promise => { + try { + await rebuildSubGroupsMutation.mutateAsync({ divisionId, subGroupCount }); + await queryClient.invalidateQueries({ + queryKey: divisionKeys.roster(divisionId), + }); + return true; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [rebuildSubGroupsMutation, queryClient, handleUnknownError] + ); + + const reassignTeamToSubGroup = useCallback( + async ( + divisionId: GUID, + teamId: GUID, + fromStageId: GUID, + toStageId: GUID + ): Promise => { + try { + await reassignTeamToSubGroupMutation.mutateAsync({ + divisionId, + teamId, + fromStageId, + toStageId, + }); + await queryClient.invalidateQueries({ + queryKey: divisionKeys.roster(divisionId), + }); + return true; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [reassignTeamToSubGroupMutation, queryClient, handleUnknownError] + ); + + const container: IDivisionContextProps = useMemo( + () => ({ + division, + divisions, + addDivision, + generateFixtureByDivisionId, + putDivisionById, + getDivisionsByFilters, + getDivisionsById, + deleteDivisionsById, + getRoster, + enrollTeams, + unenrollTeams, + autoDistribute, + rebuildSubGroups, + reassignTeamToSubGroup, + }), + [ + division, + divisions, + addDivision, + generateFixtureByDivisionId, + putDivisionById, + getDivisionsByFilters, + getDivisionsById, + deleteDivisionsById, + getRoster, + enrollTeams, + unenrollTeams, + autoDistribute, + rebuildSubGroups, + reassignTeamToSubGroup, + ] + ); + + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/division/hook/division.hook.ts b/Club12-WebClient/src/modules/division/hook/division.hook.ts new file mode 100644 index 0000000..c657525 --- /dev/null +++ b/Club12-WebClient/src/modules/division/hook/division.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { DivisionContext } from '@/modules/division/context/division.context'; + +export const useDivision = () => { + const context = useContext(DivisionContext); + if (!context) { + throw new Error('useDivision must be used within a DivisionProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/division/queryKeys.test.ts b/Club12-WebClient/src/modules/division/queryKeys.test.ts new file mode 100644 index 0000000..40e74aa --- /dev/null +++ b/Club12-WebClient/src/modules/division/queryKeys.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { divisionKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; +import { DivisionFiltered } from '@/modules/division/type/division'; + +describe('divisionKeys', () => { + const id: GUID = '88888888-8888-8888-8888-888888888888'; + + it('list() returns the bare list literal with no trailing undefined', () => { + expect(divisionKeys.list()).toEqual(['division', 'list']); + }); + + it('list(filter) returns the filtered list literal', () => { + const filter: DivisionFiltered = { pageNumber: 1 }; + expect(divisionKeys.list(filter)).toEqual(['division', 'list', filter]); + }); + + it('byId(id) returns the by-id literal', () => { + expect(divisionKeys.byId(id)).toEqual(['division', 'byId', id]); + }); +}); diff --git a/Club12-WebClient/src/modules/division/queryKeys.ts b/Club12-WebClient/src/modules/division/queryKeys.ts new file mode 100644 index 0000000..216497a --- /dev/null +++ b/Club12-WebClient/src/modules/division/queryKeys.ts @@ -0,0 +1,10 @@ +import { DivisionFiltered } from '@/modules/division/type/division'; + +export const divisionKeys = { + list: (filter?: DivisionFiltered) => + filter === undefined + ? (['division', 'list'] as const) + : (['division', 'list', filter] as const), + byId: (idOrSlug: string) => ['division', 'byId', idOrSlug] as const, + roster: (divisionId: string) => ['division', 'roster', divisionId] as const, +}; diff --git a/Club12-WebClient/src/modules/division/service/division.service.ts b/Club12-WebClient/src/modules/division/service/division.service.ts new file mode 100644 index 0000000..aa6c125 --- /dev/null +++ b/Club12-WebClient/src/modules/division/service/division.service.ts @@ -0,0 +1,170 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + AddDivisionRequest, + DivisionFiltered, + EnrollTeamsRequest, + IDivisionResponse, + IPutDivisionRequest, + ReassignTeamToSubGroupRequest, + RebuildSubGroupsRequest, + UnenrollTeamsRequest, +} from '@/modules/division/type/division'; +import { ITeamResponse } from '@/modules/team/type/team.d'; +import { IStageResponse } from '@/modules/stage/type/stage'; + +/** + * DivisionService provides methods to interact with the divisions API. + */ +export const divisionService = { + /** + * Adds a new division. + * @param {AddDivisionRequest} division - The division data to be added. + * @returns {Promise>} - A promise that resolves with the server response. + */ + addDivision: async ( + division: AddDivisionRequest + ): Promise> => + sendPost(routes.divisions, division), + + /** + * Generates the fixture for a division based on its ID. + * @param {string} id - The ID of the division to generate the fixture for. + * @returns {Promise>} - A promise that resolves with the server response. + */ + generateFixtureByDivisionId: async (id: GUID): Promise> => + sendPost(`${routes.divisions}/${id}/generate-fixture`), + + /** + * Updates an existing division by its ID. + * @param {string} id - The ID of the division to be updated. + * @param {IPutDivisionRequest} division - The updated division data. + * @returns {Promise>} - A promise that resolves with the server response. + */ + putDivisionById: async ( + id: GUID, + division: IPutDivisionRequest + ): Promise> => + sendPut(`${routes.divisions}/${id}`, division), + + /** + * Retrieves a division by its ID or its public slug. + * @param {string} idOrSlug - The ID or slug of the division to retrieve. + * @returns {Promise>} - A promise that resolves with the division data. + */ + getDivisionsById: async ( + idOrSlug: string + ): Promise> => + sendGet(`${routes.divisions}/${idOrSlug}/detail`), + + /** + * Retrieves divisions based on provided filters. + * @param {DivisionFiltered} filter - The filters to apply when retrieving divisions. + * @returns {Promise>} - A promise that resolves with a list of divisions matching the filter. + */ + getDivisionsByFilters: async ( + filter: DivisionFiltered + ): Promise>> => + sendGet>( + routes.divisions, + withTablePageSize(filter) + ), + + /** + * Deletes a division by its ID. + * @param {string} id - The ID of the division to delete. + * @returns {Promise>} - A promise that resolves when the division is deleted. + */ + deleteDivisionsById: async (id: GUID): Promise> => + sendDelete(`${routes.divisions}/${id}`), + + /** + * Fetches every team enrolled in a division's roster. + * @param {GUID} divisionId - The division whose roster to fetch. + * @returns {Promise>} - The enrolled teams. + */ + getRoster: async (divisionId: GUID): Promise> => + sendGet(`${routes.divisions}/${divisionId}/roster`), + + /** + * Enrols one or more teams in a division's roster. + * @param {GUID} divisionId - The division to enrol the teams into. + * @param {GUID[]} teamIds - The teams to enrol. + * @returns {Promise>} - The division's roster as it stands after enrolling (200). + */ + enrollTeams: async ( + divisionId: GUID, + teamIds: GUID[] + ): Promise> => + sendPost(`${routes.divisions}/${divisionId}/roster`, { + teamIds, + } satisfies EnrollTeamsRequest), + + /** + * Removes one or more teams from a division's roster, cascading to any + * stage placement they still hold within the division. + * @param {GUID} divisionId - The division to unenrol the teams from. + * @param {GUID[]} teamIds - The teams to unenrol. + * @returns {Promise>} - The response confirming removal. + */ + unenrollTeams: async ( + divisionId: GUID, + teamIds: GUID[] + ): Promise> => + sendDelete(`${routes.divisions}/${divisionId}/roster`, undefined, { + teamIds, + } satisfies UnenrollTeamsRequest), + + /** + * Clears the division's current sub-group placements and re-runs a + * balanced random distribution over its whole roster. + * @param {GUID} divisionId - The division whose sub-groups to auto-distribute. + * @returns {Promise>} - The response confirming the redistribution. + */ + autoDistribute: async (divisionId: GUID): Promise> => + sendPost(`${routes.divisions}/${divisionId}/roster/auto-distribute`), + + /** + * Rebuilds a division's sub-group stage layer to a new count, keeping the + * roster untouched. + * @param {GUID} divisionId - The division whose sub-group count to change. + * @param {number} subGroupCount - The new sub-group count. + * @returns {Promise>} - The newly-built sub-group stages (200). + */ + rebuildSubGroups: async ( + divisionId: GUID, + subGroupCount: number + ): Promise> => + sendPost(`${routes.divisions}/${divisionId}/sub-groups/rebuild`, { + subGroupCount, + } satisfies RebuildSubGroupsRequest), + + /** + * Manually moves one enrolled team from one sub-group to another within + * the same division, without touching any other team's placement. + * @param {GUID} divisionId - The division the two sub-groups belong to. + * @param {GUID} teamId - The team to move. + * @param {GUID} fromStageId - The sub-group stage the team currently belongs to. + * @param {GUID} toStageId - The sub-group stage to move the team into. + * @returns {Promise>} - The response confirming the move. + */ + reassignTeamToSubGroup: async ( + divisionId: GUID, + teamId: GUID, + fromStageId: GUID, + toStageId: GUID + ): Promise> => + sendPost(`${routes.divisions}/${divisionId}/sub-groups/reassign`, { + teamId, + fromStageId, + toStageId, + } satisfies ReassignTeamToSubGroupRequest), +}; diff --git a/Club12-WebClient/src/modules/division/type/division.d.ts b/Club12-WebClient/src/modules/division/type/division.d.ts new file mode 100644 index 0000000..b25d7b8 --- /dev/null +++ b/Club12-WebClient/src/modules/division/type/division.d.ts @@ -0,0 +1,543 @@ +import { + FetchOptions, + Filtered, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; +import { ITeamResponse } from '@/modules/team/type/team.d'; + +/** + * Context properties and methods for managing divisions in a React application. + * These methods interact with the backend for creating, updating, fetching, and deleting divisions. + * @interface IDivisionContextProps + */ +export interface IDivisionContextProps { + division: IDivisionResponse | null; + divisions: IDivisionResponse[] | null; + /** + * Adds a new division to the system. + * @param division The details of the division to add. + * @returns A promise that resolves with the response containing the newly added division. + */ + addDivision(division: AddDivisionRequest): Promise; + + /** + * Generates fixtures for a division based on its ID. + * @param id The ID of the division for which to generate fixtures. + * @returns A promise that resolves when the fixtures are successfully generated. + */ + generateFixtureByDivisionId(id: GUID): Promise; + + /** + * Updates an existing division by its ID. + * @param id The ID of the division to update. + * @param division The updated division data. + * @returns A promise that resolves with the response containing the updated division. + */ + putDivisionById( + id: GUID, + division: IPutDivisionRequest + ): Promise; + + /** + * Fetches a division by its ID or its public slug. + * @param idOrSlug The ID or slug of the division to fetch. + * @returns A promise that resolves with the division data. + */ + getDivisionsById(idOrSlug: string): Promise; + + /** + * Fetches divisions based on filters and pagination. + * @param filter The filter criteria to apply when fetching divisions. + * @returns A promise that resolves with a paginated response containing filtered divisions. + */ + getDivisionsByFilters( + filter: DivisionFiltered, + options?: FetchOptions + ): Promise | void>; + + /** + * Deletes a division by its ID. + * @param id The ID of the division to delete. + * @returns A promise resolving to `true` if the division was deleted, + * `false` if the request failed (the global error is already reported + * either way). + */ + deleteDivisionsById(id: GUID): Promise; + + /** + * Fetches every team currently enrolled in a division's roster + * (`DivisionTeamRegistration`), independent of any stage placement. The + * authoritative source of "who is in this division" — including a + * playoffs-only division with no group stage. + * @param divisionId The division whose roster to fetch. + * @returns A promise resolving to the enrolled teams, or void on failure. + */ + getRoster(divisionId: GUID): Promise; + + /** + * Enrols one or more teams in a division's roster. Rejected (409) when a + * team already holds a registration in another regular division of the + * same tournament, or the tournament structure is locked. + * @param divisionId The division to enrol the teams into. + * @param teamIds The teams to enrol. + * @returns A promise resolving to true on success, or void on failure. + */ + enrollTeams(divisionId: GUID, teamIds: GUID[]): Promise; + + /** + * Removes one or more teams from a division's roster. Cascades: any + * `StageTeamMatch` the team still holds within the division's stages is + * removed in the same operation. + * @param divisionId The division to unenrol the teams from. + * @param teamIds The teams to unenrol. + * @returns A promise resolving to true on success, or void on failure. + */ + unenrollTeams(divisionId: GUID, teamIds: GUID[]): Promise; + + /** + * Clears the division's current sub-group placements and re-runs a + * balanced random distribution over its whole roster (HU-122). Always + * balanced, not fill-only-empties. + * @param divisionId The division whose sub-groups to auto-distribute. + * @returns A promise resolving to true on success, or void on failure. + */ + autoDistribute(divisionId: GUID): Promise; + + /** + * Rebuilds a division's sub-group stage layer to a new count, keeping the + * roster untouched, and re-runs the balanced distribution over it (HU-123). + * @param divisionId The division whose sub-group count to change. + * @param subGroupCount The new sub-group count. + * @returns A promise resolving to true on success, or void on failure. + */ + rebuildSubGroups(divisionId: GUID, subGroupCount: number): Promise; + + /** + * Manually moves one enrolled team from one sub-group to another within the + * same division (HU-122), without touching any other team's placement. + * Rejected (409) when the move would drop the source sub-group below the + * minimum size, the team is not currently placed in `fromStageId`, or the + * two stages belong to different divisions. + * @param divisionId The division the two sub-groups belong to. + * @param teamId The team to move. + * @param fromStageId The sub-group stage the team currently belongs to. + * @param toStageId The sub-group stage to move the team into. + * @returns A promise resolving to true on success, or void on failure. + */ + reassignTeamToSubGroup( + divisionId: GUID, + teamId: GUID, + fromStageId: GUID, + toStageId: GUID + ): Promise; +} + +/** + * One position-range → playoff-destination entry (HU-45) sent with a + * division so the backend can seed multiple cups from the final table + * (HU-81). Field names mirror the backend `PlayoffMappingRequest` DTO. + * @interface PlayoffMappingRequest + */ +export interface PlayoffMappingRequest { + /** First standings position in the range (1-based, inclusive). */ + fromPosition: number; + + /** Last standings position in the range (1-based, inclusive). */ + toPosition: number; + + /** The destination cup's BracketName (e.g. "Copa Oro"). */ + destination: string; +} + +/** + * The request body structure for adding a new division. + * @interface AddDivisionRequest + */ +export interface AddDivisionRequest { + /** + * The name of the division. + * @type {string} + */ + name: string; + + /** + * The ID of the tournament to which the division belongs. + * @type {GUID} + */ + tournamentId: GUID; + + /** + * Marks this division as a cross-division cup that intentionally draws + * teams from every other division in the tournament (e.g. an + * admin-named "Copa Club12"), exempt from the "one team, one division" + * rule. Defaults to false. + * @type {boolean} + */ + isCrossDivisionCup?: boolean; + + /** + * For a cross-division cup (HU-110): how many teams advance from each of + * the cup's group stages into the pooled knockout bracket. Only meaningful + * when `isCrossDivisionCup` is true; the backend auto-sizes the bracket's + * first round from the pooled top-`qualifiersPerGroup` of every group. + * @type {number} + */ + qualifiersPerGroup?: number; + + /** + * Points awarded for a win in this division's standings (HU-79). + * Omit to let the backend default to 2. + * @type {number} + */ + pointsForWin?: number; + + /** + * Points awarded for a loss in this division's standings (HU-79). + * Omit to let the backend default to 1. + * @type {number} + */ + pointsForLoss?: number; + + /** + * Competitive category (gender) of the division (HU-48). MUST match the + * parent tournament's category — the backend rejects a division whose + * category differs from its tournament, and `Division.Category` defaults to + * Masculine server-side. The wizard therefore sends the tournament's + * category on every division so a Feminine tournament's zones are created + * as Feminine and not rejected. + * @type {TournamentCategory} + */ + category?: TournamentCategory; + + /** + * Optional position-range → playoff-destination mappings (HU-45) the + * wizard sends so the backend can seed multiple cups (HU-81). Ranges + * must not overlap. + * @type {PlayoffMappingRequest[]} + */ + playoffMappings?: PlayoffMappingRequest[]; +} + +/** + * One standings-position range that qualifies to a playoff cup (HU-45), + * shaped for the public standings table so it can highlight the qualifying + * rows and render a per-cup legend. Mirrors the backend + * `QualificationRangeResponse` DTO. + * @interface QualificationRange + */ +export interface QualificationRange { + /** First standings position in the range (1-based, inclusive). */ + fromPosition: number; + + /** Last standings position in the range (1-based, inclusive). */ + toPosition: number; + + /** The cup the teams in this range qualify for (e.g. "Copa Oro"). */ + cupName: string; + + /** + * The cup's rank, top-down: 0 is the top cup ("Copa Oro"), 1 the next, and + * so on. Drives the color painted on each qualifying row. + */ + order: number; +} + +/** + * The response structure for a division, including details about the division, its matches, and positions. + * @interface IDivisionResponse + */ +export interface IDivisionResponse { + /** + * The unique identifier of the division. + * @type {GUID} + */ + id: GUID; + + /** + * The name of the division. + * @type {string} + */ + name: string; + + /** + * The unique, URL-friendly identifier used in public division links. + * @type {string} + */ + slug: string; + + /** + * Indicates whether the division has finished. + * @type {boolean} + */ + isFinished: boolean; + + /** + * The list of positions for teams in the division. For a multi-group + * cross-division cup this is the pooled union across every internal group + * (so a team counter reflects all groups); use `groupStandings` to render + * one table per group. + * @type {Position[]} + */ + positions?: Position[]; + + /** + * One standings table per Group stage (HU-110). A regular zone has a single + * entry; a multi-group cross-division cup has one per internal group + * ("Grupo 1".."Grupo N"). Absent/empty when the division has no group stage. + * @type {GroupStandings[]} + */ + groupStandings?: GroupStandings[]; + + /** + * The ID of the tournament to which the division belongs. + * @type {GUID} + */ + tournamentId: GUID; + + /** + * The parent tournament's slug, when it was resolved by the backend; null + * otherwise. Prefer this over `tournamentId` when building a link back to + * the tournament, so the URL never shows a raw UUID. + * @type {string | null} + */ + tournamentSlug?: string | null; + + /** + * Whether this division is a cross-division cup (exempt from the "one + * team, one division" rule). + * @type {boolean} + */ + isCrossDivisionCup: boolean; + + /** + * For a cross-division cup (HU-110): how many teams advance from EACH of + * the cup's internal groups into the pooled knockout bracket. Meaningless + * (defaults to 1) outside a cross-division cup. + * @type {number} + */ + qualifiersPerGroup?: number; + + /** + * Competitive category (gender) of the division — matches its tournament. + * Used to tell apart same-named zones across masculine/feminine tournaments. + * @type {TournamentCategory} + */ + category?: TournamentCategory; + + /** + * The standings-position ranges that qualify to a playoff cup (HU-45), + * ordered top-down (order 0 = top cup). Lets the public standings table + * highlight the qualifying rows and render a per-cup legend. Absent/empty + * when the division has no playoff mappings. + * @type {QualificationRange[]} + */ + qualificationRanges?: QualificationRange[]; +} + +/** + * Standings for a single Group stage within a division. A regular zone has + * exactly one; a multi-group cross-division cup (HU-110) has one per internal + * group, each computed only over that group's own matches. + * @type GroupStandings + */ +export type GroupStandings = { + /** The id of the Group stage these standings belong to. */ + stageId: GUID; + + /** The Group stage's name, used as the table label (e.g. "Grupo 1"). */ + stageName: string; + + /** The ordered standings for the teams in this group. */ + positions: Position[]; +}; + +/** + * The structure for a position in a division, including team statistics. + * @type Position + */ +export type Position = { + /** + * The unique identifier of the team. + * @type {string} + */ + teamId: GUID; + + /** + * The name of the team. + * @type {string} + */ + teamName: string; + + /** + * The URL of the team's logo. + * @type {string} + */ + logoUrl: string; + + /** + * The number of matches the team has played. + * @type {number} + */ + matchesPlayed: number; + + /** + * The number of matches the team has won. + * @type {number} + */ + wins: number; + + /** + * The number of matches the team has lost. + * @type {number} + */ + losses: number; + + /** + * The number of points the team has scored. + * @type {number} + */ + pointsFor: number; + + /** + * The number of points scored against the team. + * @type {number} + */ + pointsAgainst: number; + + /** + * The difference between points scored and points against. + * @type {number} + */ + pointsDifference: number; + + /** + * The total points the team has earned. Any disciplinary deduction + * (see `pointDeduction`) is already subtracted from this value. + * @type {number} + */ + points: number; + + /** + * The disciplinary point deduction (deducción de puntos) applied to this + * team, when any. Absent when the team has no deduction. The subtraction is + * already reflected in `points`; this only carries the amount and reason so + * the standings can show a subtle "-N (motivo)" note. + */ + pointDeduction?: AppliedPointDeduction; +}; + +/** + * The point-deduction summary attached to a standings row when a team carries + * one or more disciplinary deductions. Mirrors the backend + * `AppliedPointDeductionResponse` DTO. + * @type AppliedPointDeduction + */ +export type AppliedPointDeduction = { + /** The total table points subtracted from the team. Always positive. */ + points: number; + + /** The combined disciplinary reason(s). */ + reason: string; +}; + +/** + * The filter criteria for fetching divisions, which extends from PutDivisionRequest and Filtered. + * This includes the `isFinished` property to filter divisions by their completion status. + * @interface DivisionFiltered + * @extends IPutDivisionRequest + * @extends Filtered + */ +export interface DivisionFiltered extends Filtered { + /** + * Indicates whether to fetch finished divisions only. + * @type {boolean} + */ + isFinished?: boolean; + + tournamentId?: GUID; + + /** + * The updated name of the division. + * @type {string} + */ + name?: string; +} + +/** + * The request body structure for updating an existing division. + * @interface PutDivisionRequest + */ +export interface IPutDivisionRequest { + /** + * The updated name of the division. + * @type {string} + */ + name: string; + + isFinished: boolean; +} + +export interface IDivisionPropsView { + name: string; +} + +/** + * The request body to enrol teams in a division's roster. + * @interface EnrollTeamsRequest + */ +export interface EnrollTeamsRequest { + teamIds: GUID[]; +} + +/** + * The request body to remove teams from a division's roster. + * @interface UnenrollTeamsRequest + */ +export interface UnenrollTeamsRequest { + teamIds: GUID[]; +} + +/** + * The request body to change a division's sub-group count (HU-123). + * @interface RebuildSubGroupsRequest + */ +export interface RebuildSubGroupsRequest { + subGroupCount: number; +} + +/** + * The request body to manually move one team from one sub-group to another + * within the same division (HU-122). + * @interface ReassignTeamToSubGroupRequest + */ +export interface ReassignTeamToSubGroupRequest { + teamId: GUID; + fromStageId: GUID; + toStageId: GUID; +} + +/** + * The minimal response structure for a division, as embedded within a tournament response. + * @interface IMinimalDivisionResponse + */ +export interface IMinimalDivisionResponse { + /** + * The unique identifier of the division. + * @type {GUID} + */ + id: GUID; + + /** + * The name of the division. + * @type {string} + */ + name: string; + + /** + * Indicates whether the division has finished. + * @type {boolean} + */ + isFinished: boolean; +} diff --git a/Club12-WebClient/src/modules/division/utils/divisionStructureSummary.test.ts b/Club12-WebClient/src/modules/division/utils/divisionStructureSummary.test.ts new file mode 100644 index 0000000..114b396 --- /dev/null +++ b/Club12-WebClient/src/modules/division/utils/divisionStructureSummary.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { StageType } from '@/modules/stage/type/stage'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; +import { ITournamentStructureResponse } from '@/modules/tournament/type/tournament.d'; +import { findDivisionStructure } from './divisionStructureSummary'; + +const groupStage = (name: string): ITournamentStructureResponse['divisions'][number]['stages'][number] => ({ + name, + bracketName: null, + stageType: StageType.Group, + isElimination: false, + order: 0, + bestOf: 1, + roundRobinLegs: 2, +}); + +const cupStages = (bracketName: string) => [ + { + name: `Semifinal ${bracketName}`, + bracketName, + stageType: StageType.SemiFinal, + isElimination: true, + order: 1, + bestOf: 3, + roundRobinLegs: 1, + }, + { + name: `Final ${bracketName}`, + bracketName, + stageType: StageType.Final, + isElimination: true, + order: 2, + bestOf: 5, + roundRobinLegs: 1, + }, +]; + +const tournamentStructure: ITournamentStructureResponse = { + name: 'Apertura 2026', + category: TournamentCategory.Masculine, + divisions: [ + { + name: 'Zona A', + isCrossDivisionCup: false, + pointsForWin: 3, + pointsForLoss: 0, + qualifiersPerGroup: 1, + playoffMappings: [{ id: 'mapping-1', fromPosition: 1, toPosition: 4, destination: 'Copa Oro' } as never], + stages: [groupStage('Fase de Grupos'), ...cupStages('Copa Oro')], + }, + { + name: 'Copa Club12', + isCrossDivisionCup: true, + pointsForWin: 2, + pointsForLoss: 1, + qualifiersPerGroup: 2, + playoffMappings: [], + stages: [groupStage('Grupo 1'), groupStage('Grupo 2'), ...cupStages('Copa Club12')], + }, + ], +}; + +describe('findDivisionStructure', () => { + it('resolves a regular zone by name', () => { + const summary = findDivisionStructure(tournamentStructure, 'Zona A'); + + expect(summary).not.toBeNull(); + expect(summary!.zone).toBeDefined(); + expect(summary!.crossCup).toBeUndefined(); + expect(summary!.zone!.name).toBe('Zona A'); + expect(summary!.zone!.cups).toHaveLength(1); + expect(summary!.zone!.cups[0].name).toBe('Copa Oro'); + expect(summary!.review).toEqual([]); + }); + + it('resolves the cross-division cup by name', () => { + const summary = findDivisionStructure(tournamentStructure, 'Copa Club12'); + + expect(summary).not.toBeNull(); + expect(summary!.crossCup).toBeDefined(); + expect(summary!.zone).toBeUndefined(); + expect(summary!.crossCup!.groupCount).toBe(2); + expect(summary!.crossCup!.cups[0].name).toBe('Copa Club12'); + }); + + it('returns null when no division in the tournament matches the given name', () => { + expect(findDivisionStructure(tournamentStructure, 'Zona Inexistente')).toBeNull(); + }); +}); diff --git a/Club12-WebClient/src/modules/division/utils/divisionStructureSummary.ts b/Club12-WebClient/src/modules/division/utils/divisionStructureSummary.ts new file mode 100644 index 0000000..083b6c3 --- /dev/null +++ b/Club12-WebClient/src/modules/division/utils/divisionStructureSummary.ts @@ -0,0 +1,43 @@ +import { ITournamentStructureResponse } from '@/modules/tournament/type/tournament.d'; +import { structureToWizardState } from '@/views/tournament/wizard/cloneWizard'; +import { CrossCupConfig, ZoneConfig } from '@/views/tournament/wizard/types'; + +/** + * One division's structure, resolved out of its tournament's full structure + * tree — a regular zone or the cross-division cup, never both. `review` + * carries any derivation mismatches found while reconstructing it (the same + * checks the tournament-cloning reverse-mapper runs), surfaced here as + * advisory notices rather than silently guessed. + */ +export interface DivisionStructureSummary { + zone?: ZoneConfig; + crossCup?: CrossCupConfig; + review: string[]; +} + +/** + * Resolves a single division's structure by name out of its tournament's + * structure tree, reusing the tournament-cloning reverse-mapper + * ({@link structureToWizardState}) instead of a second, parallel parser. + * Returns null when no division in the tournament matches the given name. + */ +export const findDivisionStructure = ( + tournamentStructure: ITournamentStructureResponse, + divisionName: string +): DivisionStructureSummary | null => { + const { state, review } = structureToWizardState( + tournamentStructure, + tournamentStructure.category + ); + + const zone = state.zones.find(candidate => candidate.name === divisionName); + if (zone) { + return { zone, review }; + } + + if (state.crossCup.enabled && state.crossCup.name === divisionName) { + return { crossCup: state.crossCup, review }; + } + + return null; +}; diff --git a/Club12-WebClient/src/modules/division/utils/qualificationRange.test.ts b/Club12-WebClient/src/modules/division/utils/qualificationRange.test.ts new file mode 100644 index 0000000..21156b7 --- /dev/null +++ b/Club12-WebClient/src/modules/division/utils/qualificationRange.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest'; +import { QualificationRange } from '@/modules/division/type/division.d'; +import { + buildCrossCupGroupQualificationRange, + cupTierColor, + cupTierMarker, + findQualificationRange, +} from './qualificationRange'; +import { cupTier } from '@/design/tokens'; + +const ranges: QualificationRange[] = [ + { fromPosition: 1, toPosition: 4, cupName: 'Copa Oro', order: 0 }, + { fromPosition: 5, toPosition: 8, cupName: 'Copa Plata', order: 1 }, +]; + +describe('findQualificationRange', () => { + it('matches the first position of a range (inclusive lower bound)', () => { + expect(findQualificationRange(ranges, 1)?.cupName).toBe('Copa Oro'); + }); + + it('matches the last position of a range (inclusive upper bound)', () => { + expect(findQualificationRange(ranges, 8)?.cupName).toBe('Copa Plata'); + }); + + it('matches a position inside a range', () => { + expect(findQualificationRange(ranges, 6)?.cupName).toBe('Copa Plata'); + }); + + it('returns undefined for a position outside every range', () => { + expect(findQualificationRange(ranges, 9)).toBeUndefined(); + }); + + it('returns undefined when there are no ranges', () => { + expect(findQualificationRange(undefined, 1)).toBeUndefined(); + expect(findQualificationRange([], 1)).toBeUndefined(); + }); +}); + +describe('cupTierColor', () => { + it('maps order to gold, silver, bronze then the accent', () => { + expect(cupTierColor(0)).toBe(cupTier.gold); + expect(cupTierColor(1)).toBe(cupTier.silver); + expect(cupTierColor(2)).toBe(cupTier.bronze); + expect(cupTierColor(3)).toBe(cupTier.accent); + expect(cupTierColor(7)).toBe(cupTier.accent); + }); +}); + +describe('buildCrossCupGroupQualificationRange', () => { + it('builds a single range covering positions 1..qualifiersPerGroup, named after the cup', () => { + const result = buildCrossCupGroupQualificationRange({ + name: 'Copa Club12', + qualifiersPerGroup: 2, + }); + + expect(result).toEqual([ + { fromPosition: 1, toPosition: 2, cupName: 'Copa Club12', order: 0 }, + ]); + }); + + it('returns undefined when qualifiersPerGroup is missing or non-positive', () => { + expect( + buildCrossCupGroupQualificationRange({ name: 'Zona A', qualifiersPerGroup: undefined }) + ).toBeUndefined(); + expect( + buildCrossCupGroupQualificationRange({ name: 'Zona A', qualifiersPerGroup: 0 }) + ).toBeUndefined(); + }); +}); + +describe('cupTierMarker', () => { + it('returns a distinct marker per tier so the legend does not rely on color alone', () => { + expect(cupTierMarker(0)).toBe('🟡'); + expect(cupTierMarker(1)).toBe('⚪'); + expect(cupTierMarker(2)).toBe('🟠'); + expect(cupTierMarker(4)).toBe('🔶'); + }); +}); diff --git a/Club12-WebClient/src/modules/division/utils/qualificationRange.ts b/Club12-WebClient/src/modules/division/utils/qualificationRange.ts new file mode 100644 index 0000000..0722869 --- /dev/null +++ b/Club12-WebClient/src/modules/division/utils/qualificationRange.ts @@ -0,0 +1,76 @@ +import { IDivisionResponse, QualificationRange } from '@/modules/division/type/division.d'; +import { cupTier } from '@/design/tokens'; + +/** + * Finds the qualification range whose [fromPosition, toPosition] span contains + * the given 1-based standings position, or `undefined` when no range covers it + * (that row does not qualify to any cup). Ranges never overlap (the backend + * enforces it), so at most one can match. + */ +export const findQualificationRange = ( + ranges: QualificationRange[] | undefined, + position: number +): QualificationRange | undefined => + ranges?.find(range => position >= range.fromPosition && position <= range.toPosition); + +/** + * The tier color a cup is painted with, by its top-down order: 0 gold, 1 + * silver, 2 bronze, and the brand-orange accent for any further cup. Reads the + * centralized design tokens so the standings highlight and legend never + * hardcode a hex. + */ +export const cupTierColor = (order: number): string => { + switch (order) { + case 0: + return cupTier.gold; + case 1: + return cupTier.silver; + case 2: + return cupTier.bronze; + default: + return cupTier.accent; + } +}; + +/** + * HU-110/HU-112: a multi-group cross-division cup pools the top + * `qualifiersPerGroup` of EVERY internal group into one bracket — there is no + * per-division `PlayoffMappings` breakdown to derive from (the cross cup + * carries none, see backend `DivisionProfile.cs`). This is the single range + * every group's standings table highlights, named after the cup itself. + * Returns `undefined` when the division has no positive qualifiers-per-group + * (a regular zone, or a misconfigured cross cup). + */ +export const buildCrossCupGroupQualificationRange = ( + division: Pick +): QualificationRange[] | undefined => { + if (!division.qualifiersPerGroup || division.qualifiersPerGroup < 1) { + return undefined; + } + + return [ + { + fromPosition: 1, + toPosition: division.qualifiersPerGroup, + cupName: division.name, + order: 0, + }, + ]; +}; + +/** + * A small emoji marker per tier, so the legend conveys the cup rank without + * relying on color alone (the cup name text carries the full meaning). + */ +export const cupTierMarker = (order: number): string => { + switch (order) { + case 0: + return '🟡'; + case 1: + return '⚪'; + case 2: + return '🟠'; + default: + return '🔶'; + } +}; diff --git a/Club12-WebClient/src/modules/division/utils/sortPositions.ts b/Club12-WebClient/src/modules/division/utils/sortPositions.ts new file mode 100644 index 0000000..b9fbbac --- /dev/null +++ b/Club12-WebClient/src/modules/division/utils/sortPositions.ts @@ -0,0 +1,17 @@ +import { Position } from '@/modules/division/type/division.d'; + +/** + * Sorts division standings rows by points, then point difference, then + * points scored — the same ranking rule used across the standings table + * and the printable results sheet. + */ +export const sortPositions = (positions: Position[]): Position[] => + [...positions].sort((a, b) => { + if (b.points !== a.points) { + return b.points - a.points; + } + if (b.pointsDifference !== a.pointsDifference) { + return b.pointsDifference - a.pointsDifference; + } + return b.pointsFor - a.pointsFor; + }); diff --git a/Club12-WebClient/src/modules/error/context/error.context.test.tsx b/Club12-WebClient/src/modules/error/context/error.context.test.tsx new file mode 100644 index 0000000..a0ec757 --- /dev/null +++ b/Club12-WebClient/src/modules/error/context/error.context.test.tsx @@ -0,0 +1,86 @@ +import { act, renderHook } from '@testing-library/react'; +import { AxiosError } from 'axios'; +import type { ReactNode } from 'react'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import Swal from 'sweetalert2'; +import { ErrorProvider } from '@/modules/error/context/error.context'; +import { useError } from '@/modules/error/hooks/error.hock'; + +vi.mock('sweetalert2', () => ({ + default: { + fire: vi.fn(), + getContainer: vi.fn().mockReturnValue(null), + }, +})); + +const mockedSwalFire = vi.mocked(Swal.fire); + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('ErrorProvider — setMessage toast duration', () => { + /** + * Regression test for a "silent failure" bug: a rejected mutation (e.g. a + * match edit or a score-sheet submit) DID receive a well-formed backend + * error and DID render a toast, but the toast auto-dismissed after 1500ms + * with no button — the same fast timer used for a quick success + * confirmation. Backend validation messages are often a full sentence + * (roster/eligibility rules, "cannot edit a started match", etc.) that + * can't be read that fast, so in practice it looked exactly like no + * feedback was shown at all. Errors must instead stay up until the user + * dismisses them. + */ + it('shows an error toast that waits for the user to dismiss it, not a 1500ms timer', () => { + const { result } = renderHook(() => useError(), { wrapper }); + + act(() => { + result.current.setMessage(400, ['No se puede editar un partido que ya arrancó o finalizó.']); + }); + + expect(mockedSwalFire).toHaveBeenCalledTimes(1); + const options = mockedSwalFire.mock.calls[0][0] as unknown as Record; + expect(options.icon).toBe('error'); + expect(options.showConfirmButton).toBe(true); + expect(options.timer).toBeUndefined(); + }); + + it('keeps the fast auto-dismiss timer for a success message', () => { + const { result } = renderHook(() => useError(), { wrapper }); + + act(() => { + result.current.setMessage(200, ['Partido actualizado']); + }); + + expect(mockedSwalFire).toHaveBeenCalledTimes(1); + const options = mockedSwalFire.mock.calls[0][0] as unknown as Record; + expect(options.icon).toBe('success'); + expect(options.showConfirmButton).toBe(false); + expect(options.timer).toBe(1500); + }); + + it('setError (used for unknown/axios errors) also surfaces a dismiss-required toast', () => { + const { result } = renderHook(() => useError(), { wrapper }); + + const error = new AxiosError('Request failed with status code 400'); + Object.assign(error, { + response: { + status: 400, + data: { detail: 'No se puede editar un partido que ya arrancó o finalizó.' }, + }, + }); + + act(() => { + result.current.setError(error); + }); + + expect(mockedSwalFire).toHaveBeenCalledTimes(1); + const options = mockedSwalFire.mock.calls[0][0] as unknown as Record; + expect(options.showConfirmButton).toBe(true); + expect(options.timer).toBeUndefined(); + }); +}); diff --git a/Club12-WebClient/src/modules/error/context/error.context.tsx b/Club12-WebClient/src/modules/error/context/error.context.tsx new file mode 100644 index 0000000..f538ccf --- /dev/null +++ b/Club12-WebClient/src/modules/error/context/error.context.tsx @@ -0,0 +1,121 @@ +import { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { AxiosError } from 'axios'; +import Swal from 'sweetalert2'; +import { ProviderProps } from '@/modules/core/types/types'; +import { BadRequestResponse, IErrorContextProp } from '@/modules/error/type/error.d'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; +import { extractErrorMessage } from '@/modules/error/utils/extractErrorMessage'; +import { getTheme } from '@/theme'; + +const theme = getTheme('dark'); +const DIALOG_BACKGROUND = theme.palette.background.paper; +const DIALOG_TEXT_COLOR = theme.palette.text.primary; +const CONFIRM_BUTTON_COLOR = theme.palette.primary.main; + +export const ErrorContext = createContext( + undefined +); + +const isBadRequestResponse = (data: unknown): data is BadRequestResponse => { + return typeof data === 'object' && data !== null && 'title' in data; +}; + +export const ErrorProvider: React.FC = ({ children }) => { + const [errors, setErrors] = useState([]); + // Remembers the last toast so an identical message fired again within a short + // window (e.g. two chained requests that both succeed, or the same error + // surfaced by both an interceptor and a catch) shows only ONCE instead of + // stacking repeated alerts. + const lastToastRef = useRef<{ message: string; at: number }>({ + message: '', + at: 0, + }); + + const setMessage = useCallback((status: number, message: string[]) => { + const isError = status >= 400; + const stat = isError ? 'error' : 'success'; + const messages = message.join(', '); + + const now = Date.now(); + if ( + lastToastRef.current.message === messages && + now - lastToastRef.current.at < 2500 + ) { + return; + } + lastToastRef.current = { message: messages, at: now }; + + // A success toast is a quick "yep, saved" the user already expects, so it + // auto-dismisses fast. An error is often a full backend validation + // sentence (e.g. a roster/eligibility rule) the user has to actually + // read and act on — the same 1500ms timer made it vanish long before + // anyone could read it, which looked indistinguishable from no feedback + // at all. Errors instead stay up until the user dismisses them, the same + // "must be acknowledged" convention confirmDialog.ts's notifyError/ + // confirmAction already use for messages that require reading. + void Swal.fire({ + position: 'center', + icon: stat, + title: messages, + showConfirmButton: isError, + confirmButtonColor: CONFIRM_BUTTON_COLOR, + timer: isError ? undefined : 1500, + background: DIALOG_BACKGROUND, + color: DIALOG_TEXT_COLOR, + // Keep toasts above MUI's modal layer so one fired while a Dialog is open + // is not hidden behind it. + didOpen: () => { + const container = Swal.getContainer(); + if (container) { + container.style.zIndex = '2000'; + } + }, + }); + }, []); + + /** + * Only adds the new error message if it isn't already present, to avoid + * showing duplicate error messages for the same underlying failure. + */ + const setError = useCallback( + (error: AxiosError) => { + const data = error.response?.data; + const message = extractErrorMessage(error); + const status = + (isBadRequestResponse(data) ? (data.statusCode ?? data.status) : undefined) ?? + error.response?.status ?? + HttpStatus.InternalServerError; + + setErrors(prevErrors => + prevErrors.includes(message) ? prevErrors : [...prevErrors, message] + ); + setMessage(status, [message]); + }, + [setMessage] + ); + + useEffect(() => { + if (errors.length > 0) { + const timer = setTimeout(() => { + setErrors([]); + }, 5000); + return () => clearTimeout(timer); + } + }, [errors]); + + const value = useMemo( + () => ({ errors, setError, setMessage }), + [errors, setError, setMessage] + ); + + return ( + {children} + ); +}; diff --git a/Club12-WebClient/src/modules/error/hooks/error.hock.ts b/Club12-WebClient/src/modules/error/hooks/error.hock.ts new file mode 100644 index 0000000..bcb3a32 --- /dev/null +++ b/Club12-WebClient/src/modules/error/hooks/error.hock.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ErrorContext } from '@/modules/error/context/error.context'; + +export const useError = () => { + const context = useContext(ErrorContext); + if (!context) { + throw new Error('useError must be used whithin an Error Provider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/error/hooks/useUnknownErrorHandler.ts b/Club12-WebClient/src/modules/error/hooks/useUnknownErrorHandler.ts new file mode 100644 index 0000000..e1bd314 --- /dev/null +++ b/Club12-WebClient/src/modules/error/hooks/useUnknownErrorHandler.ts @@ -0,0 +1,25 @@ +import { AxiosError } from 'axios'; +import { useCallback } from 'react'; +import { useError } from '@/modules/error/hooks/error.hock'; +import { ERROR_MESSAGES } from '@/modules/core/constants/constants'; + +/** + * Returns a stable callback that reports an unknown error to the global + * error context, wrapping non-Axios errors in a generic AxiosError so the + * rest of the app only ever has to handle one error shape. + */ +export const useUnknownErrorHandler = () => { + const { setError } = useError(); + + return useCallback( + (error: unknown) => { + if (error instanceof AxiosError) { + setError(error); + return; + } + + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + }, + [setError] + ); +}; diff --git a/Club12-WebClient/src/modules/error/type/error.d.ts b/Club12-WebClient/src/modules/error/type/error.d.ts new file mode 100644 index 0000000..4d4f88b --- /dev/null +++ b/Club12-WebClient/src/modules/error/type/error.d.ts @@ -0,0 +1,13 @@ +export interface BadRequestResponse { + title?: string; + detail?: string; + statusCode?: number; + status?: number; + errors?: Record; +} + +interface IErrorContextProp { + errors: string[] | null; + setError: (value: AxiosError) => void; + setMessage: (status: number, message: string[]) => void; +} diff --git a/Club12-WebClient/src/modules/error/utils/extractErrorMessage.ts b/Club12-WebClient/src/modules/error/utils/extractErrorMessage.ts new file mode 100644 index 0000000..9ed575b --- /dev/null +++ b/Club12-WebClient/src/modules/error/utils/extractErrorMessage.ts @@ -0,0 +1,57 @@ +import { AxiosError } from 'axios'; +import { BadRequestResponse } from '@/modules/error/type/error.d'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; +import { ERROR_MESSAGES } from '@/modules/core/constants/constants'; + +const isBadRequestResponse = (data: unknown): data is BadRequestResponse => { + return typeof data === 'object' && data !== null && 'title' in data; +}; + +/** + * 502/503/504 never come from our own API — they're the reverse proxy + * (Cloudflare) reporting the backend is unreachable/restarting, typically + * during a deploy. Its JSON error page also happens to carry a `title` + * field, which satisfies isBadRequestResponse and would otherwise leak its + * raw English text (e.g. "Bad Gateway") straight to the user instead of + * falling back to a Spanish message. + */ +const isGatewayErrorStatus = (status?: number): boolean => + status === HttpStatus.BadGateway || + status === HttpStatus.ServiceUnavailable || + status === HttpStatus.GatewayTimeout; + +/** + * ASP.NET returns two shapes of problem response: a plain ProblemDetails + * with a `detail` string, or a ValidationProblemDetails with an `errors` + * dictionary of field -> messages. This flattens either into one + * human-readable message instead of falling back to the raw axios error + * text (e.g. "Network Error", "Request failed with status code 400"). + */ +const extractMessage = (data: BadRequestResponse): string => { + if (data.errors) { + return Object.values(data.errors).flat().join(' '); + } + return data.detail ?? data.title ?? ERROR_MESSAGES.GENERIC_ERROR; +}; + +/** + * The same message-extraction rules ErrorContext's setError uses to build + * its toast, exposed standalone for callers that need the actual backend + * detail in hand — e.g. to show it inside their own persistent dialog, + * rather than relying on the global toast alone. + */ +export const extractErrorMessage = (error: unknown): string => { + if (!(error instanceof AxiosError)) { + return ERROR_MESSAGES.GENERIC_ERROR; + } + + const data = error.response?.data; + + if (isGatewayErrorStatus(error.response?.status)) { + return ERROR_MESSAGES.SERVER_UNAVAILABLE; + } + if (isBadRequestResponse(data)) { + return extractMessage(data); + } + return error.response ? ERROR_MESSAGES.GENERIC_ERROR : ERROR_MESSAGES.NETWORK_ERROR; +}; diff --git a/Club12-WebClient/src/modules/match/context/match.context.test.tsx b/Club12-WebClient/src/modules/match/context/match.context.test.tsx new file mode 100644 index 0000000..1911317 --- /dev/null +++ b/Club12-WebClient/src/modules/match/context/match.context.test.tsx @@ -0,0 +1,130 @@ +import { act, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import Swal from 'sweetalert2'; +import { ErrorProvider } from '@/modules/error/context/error.context'; +import { MatchProvider } from '@/modules/match/context/match.context'; +import { useMatch } from '@/modules/match/hook/match.hook'; +import { matchService } from '@/modules/match/service/match.service'; +import type { GUID } from '@/modules/core/types/types'; +import type { IMatchResponse } from '@/modules/match/type/match'; +import { MatchType } from '@/modules/core/enum/match/matchType'; + +vi.mock('@/modules/match/service/match.service'); +vi.mock('sweetalert2', () => ({ + default: { + fire: vi.fn(), + getContainer: vi.fn().mockReturnValue(null), + }, +})); + +const mockedPutMatchByMatchId = vi.mocked(matchService.putMatchByMatchId); +const mockedPutMatchScoreByMatchId = vi.mocked( + matchService.putMatchScoreByMatchId +); +const mockedLoadWalkOver = vi.mocked(matchService.loadWalkOver); +const mockedSuspendMatch = vi.mocked(matchService.suspendMatch); +const mockedSwalFire = vi.mocked(Swal.fire); + +const MATCH_ID = 'cccccccc-cccc-cccc-cccc-cccccccccccc' as GUID; + +const MATCH: IMatchResponse = { + id: MATCH_ID, + matchDate: '2026-03-01T20:00:00.000Z', + round: 1, + matchType: MatchType.Regular, + slug: 'match-1', + homeTeam: null, + visitorTeam: null, + isFinished: false, + winningTeamId: null, + venue: null, + stageId: null, + winningTeamName: null, +}; + +const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('MatchProvider — no duplicate success toast', () => { + /** + * matchPage.tsx already shows its own specific "Partido actualizado" / + * "Resultado cargado" / "Walkover cargado" toast for these three actions. + * The context used to ALSO fire a generic (and for putMatchByMatchId, + * mislabeled "creado") toast, so the user saw two different messages + * back to back for one action with no way to tell what actually happened. + */ + it('does not fire its own toast after putMatchByMatchId succeeds', async () => { + mockedPutMatchByMatchId.mockResolvedValueOnce({ + status: 200, + data: MATCH, + } as never); + + const { result } = renderHook(() => useMatch(), { wrapper }); + await act(async () => { + await result.current.putMatchByMatchId(MATCH_ID, { matchDate: MATCH.matchDate }); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('does not fire its own toast after putMatchScoreByMatchId succeeds', async () => { + mockedPutMatchScoreByMatchId.mockResolvedValueOnce({ + status: 200, + data: MATCH, + } as never); + + const { result } = renderHook(() => useMatch(), { wrapper }); + await act(async () => { + await result.current.putMatchScoreByMatchId(MATCH_ID, { + homeScore: 60, + visitorScore: 55, + }); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('does not fire its own toast after loadWalkOver succeeds', async () => { + mockedLoadWalkOver.mockResolvedValueOnce({ + status: 200, + data: MATCH, + } as never); + + const { result } = renderHook(() => useMatch(), { wrapper }); + await act(async () => { + await result.current.loadWalkOver(MATCH_ID, { + presentTeamId: 'dddddddd-dddd-dddd-dddd-dddddddddddd' as GUID, + }); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('still fires its own toast after suspendMatch, its only success feedback', async () => { + mockedSuspendMatch.mockResolvedValueOnce({ + status: 200, + data: MATCH, + } as never); + + const { result } = renderHook(() => useMatch(), { wrapper }); + await act(async () => { + await result.current.suspendMatch(MATCH_ID, {}); + }); + + expect(mockedSwalFire).toHaveBeenCalledTimes(1); + expect(mockedSwalFire).toHaveBeenCalledWith( + expect.objectContaining({ title: 'Partido reprogramado correctamente' }) + ); + }); +}); diff --git a/Club12-WebClient/src/modules/match/context/match.context.tsx b/Club12-WebClient/src/modules/match/context/match.context.tsx new file mode 100644 index 0000000..3b823d9 --- /dev/null +++ b/Club12-WebClient/src/modules/match/context/match.context.tsx @@ -0,0 +1,381 @@ +import { AxiosResponse } from 'axios'; +import { + createContext, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { useError } from '@/modules/error/hooks/error.hock'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { matchService } from '@/modules/match/service/match.service'; +import { + IAddMatchRequest, + ILoadWalkOverRequest, + IMatchContextProps, + MatchFiltered, + IMatchResponse, + IMinimalMatchResponse, + IPutMatchRequest, + IPutMatchResultFromSheetsRequest, + IPutMatchScoreRequest, + IRoundMatchesResponse, + ISuspendMatchRequest, +} from '@/modules/match/type/match'; +import { upsertListById } from '@/modules/core/utils/synchronizeStates'; +import { matchKeys } from '@/modules/match/queryKeys'; + +export const MatchContext = createContext( + undefined +); + +export const MatchProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [match, setMatch] = useState(null); + const [matches, setMatches] = useState(null); + + const { setMessage } = useError(); + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addMatchMutation = useMutation({ + mutationFn: matchService.addMatch, + }); + + const putMatchScoreMutation = useMutation({ + mutationFn: ({ + id, + matchScore, + }: { + id: GUID; + matchScore: IPutMatchScoreRequest; + }) => matchService.putMatchScoreByMatchId(id, matchScore), + }); + + const putMatchMutation = useMutation({ + mutationFn: ({ + id, + matchDate, + }: { + id: GUID; + matchDate: IPutMatchRequest; + }) => matchService.putMatchByMatchId(id, matchDate), + }); + + const loadMatchResultFromSheetsMutation = useMutation({ + mutationFn: ({ + id, + request, + }: { + id: GUID; + request: IPutMatchResultFromSheetsRequest; + }) => matchService.loadMatchResultFromSheets(id, request), + }); + + const deleteMatchMutation = useMutation({ + mutationFn: matchService.deleteMatchById, + }); + + const loadWalkOverMutation = useMutation({ + mutationFn: ({ + id, + request, + }: { + id: GUID; + request: ILoadWalkOverRequest; + }) => matchService.loadWalkOver(id, request), + }); + + const suspendMatchMutation = useMutation({ + mutationFn: ({ + id, + request, + }: { + id: GUID; + request: ISuspendMatchRequest; + }) => matchService.suspendMatch(id, request), + }); + + const generateMatchesMutation = useMutation({ + mutationFn: matchService.generateMatches, + }); + + useEffect(() => { + if (!match) return; + + setMatches(prev => upsertListById(prev, match)); + }, [match]); + + const addMatch = useCallback( + async (match: IAddMatchRequest): Promise => { + try { + const res: AxiosResponse = + await addMatchMutation.mutateAsync(match); + if (res) { + await queryClient.invalidateQueries({ queryKey: matchKeys.list() }); + setMessage(res.status, ['El partido fue creado satisfactoriamente.']); + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addMatchMutation, queryClient, setMessage, handleUnknownError] + ); + + const putMatchScoreByMatchId = useCallback( + async ( + id: GUID, + matchScore: IPutMatchScoreRequest + ): Promise => { + try { + const res: AxiosResponse = + await putMatchScoreMutation.mutateAsync({ id, matchScore }); + if (res) { + setMatch(res.data); + queryClient.setQueryData(matchKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: matchKeys.list() }); + // matchPage.tsx already shows its own "Resultado cargado" toast on + // success — firing one here too showed two different messages + // back to back for the same action. + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putMatchScoreMutation, queryClient, handleUnknownError] + ); + + const putMatchByMatchId = useCallback( + async ( + id: GUID, + matchDate: IPutMatchRequest + ): Promise => { + try { + const res: AxiosResponse = + await putMatchMutation.mutateAsync({ id, matchDate }); + if (res) { + setMatch(res.data); + queryClient.setQueryData(matchKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: matchKeys.list() }); + // matchPage.tsx already shows its own "Partido actualizado" toast + // on success — firing one here too showed two different messages + // back to back for the same action (and this one was mislabeled + // "creado" for what is always an update, never a create). + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putMatchMutation, queryClient, handleUnknownError] + ); + + const loadMatchResultFromSheets = useCallback( + async ( + id: GUID, + request: IPutMatchResultFromSheetsRequest + ): Promise => { + try { + const res: AxiosResponse = + await loadMatchResultFromSheetsMutation.mutateAsync({ id, request }); + if (res) { + setMatch(res.data); + queryClient.setQueryData(matchKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: matchKeys.list() }); + // The page shows its own "Resultado cargado" toast on success. + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [loadMatchResultFromSheetsMutation, queryClient, handleUnknownError] + ); + + const loadWalkOver = useCallback( + async ( + id: GUID, + request: ILoadWalkOverRequest + ): Promise => { + try { + const res: AxiosResponse = + await loadWalkOverMutation.mutateAsync({ id, request }); + if (res) { + setMatch(res.data); + queryClient.setQueryData(matchKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: matchKeys.list() }); + // matchPage.tsx already shows its own "Walkover cargado" toast on + // success — firing one here too showed two different messages + // back to back for the same action. + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [loadWalkOverMutation, queryClient, handleUnknownError] + ); + + const getMatchById = useCallback( + async (idOrSlug: string): Promise => { + try { + const res: AxiosResponse = await queryClient.fetchQuery( + { + queryKey: matchKeys.byId(idOrSlug), + queryFn: async () => await matchService.getMatchById(idOrSlug), + } + ); + + if (res) { + setMatch(res.data); + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const getMatchByFilter = useCallback( + async ( + filter: MatchFiltered + ): Promise | void> => { + try { + const res = await queryClient.fetchQuery({ + queryKey: matchKeys.list(filter), + queryFn: async () => await matchService.getMatchByFilter(filter), + }); + + if (res?.data?.items) { + setMatches(res.data.items); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const getStageMatchesByRound = useCallback( + async (stageId: GUID): Promise => { + try { + const res = await queryClient.fetchQuery({ + queryKey: matchKeys.byRound(stageId), + queryFn: async () => + await matchService.getStageMatchesByRound(stageId), + }); + + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const suspendMatch = useCallback( + async ( + id: GUID, + request: ISuspendMatchRequest + ): Promise => { + try { + const res: AxiosResponse = + await suspendMatchMutation.mutateAsync({ id, request }); + if (res) { + setMatch(res.data); + queryClient.setQueryData(matchKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: matchKeys.list() }); + await queryClient.invalidateQueries({ + queryKey: ['match', 'byRound'], + }); + setMessage(res.status, ['Partido reprogramado correctamente']); + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [suspendMatchMutation, queryClient, setMessage, handleUnknownError] + ); + + const deleteMatchById = useCallback( + async (id: GUID): Promise => { + try { + await deleteMatchMutation.mutateAsync(id); + setMatches(prev => prev?.filter(e => e.id !== id) ?? null); + if (match?.id === id) { + setMatch(null); + } + queryClient.removeQueries({ queryKey: matchKeys.byId(id) }); + await queryClient.invalidateQueries({ queryKey: matchKeys.list() }); + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [deleteMatchMutation, match, queryClient, handleUnknownError] + ); + + const generateMatchesAutomatically = useCallback( + async (id: GUID): Promise => { + try { + const res: AxiosResponse = + await generateMatchesMutation.mutateAsync(id); + if (res) { + setMatches(prev => [...(prev ?? []), ...res.data]); + await queryClient.invalidateQueries({ queryKey: matchKeys.list() }); + setMessage(res.status, ['Partidos generados correctamente']); + } + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [generateMatchesMutation, queryClient, setMessage, handleUnknownError] + ); + + const container: IMatchContextProps = useMemo( + () => ({ + match, + matches, + addMatch, + putMatchByMatchId, + putMatchScoreByMatchId, + loadMatchResultFromSheets, + loadWalkOver, + getMatchById, + getMatchByFilter, + getStageMatchesByRound, + suspendMatch, + deleteMatchById, + generateMatchesAutomatically, + }), + [ + match, + matches, + addMatch, + putMatchByMatchId, + putMatchScoreByMatchId, + loadMatchResultFromSheets, + loadWalkOver, + getMatchById, + getMatchByFilter, + getStageMatchesByRound, + suspendMatch, + deleteMatchById, + generateMatchesAutomatically, + ] + ); + return ( + {children} + ); +}; diff --git a/Club12-WebClient/src/modules/match/hook/match.hook.ts b/Club12-WebClient/src/modules/match/hook/match.hook.ts new file mode 100644 index 0000000..ab7f6a8 --- /dev/null +++ b/Club12-WebClient/src/modules/match/hook/match.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { MatchContext } from '@/modules/match/context/match.context'; + +export const useMatch = () => { + const context = useContext(MatchContext); + if (!context) { + throw new Error('useMatch must be used within a MatchProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/match/queryKeys.test.ts b/Club12-WebClient/src/modules/match/queryKeys.test.ts new file mode 100644 index 0000000..d864f6c --- /dev/null +++ b/Club12-WebClient/src/modules/match/queryKeys.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { matchKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; +import { MatchFiltered } from '@/modules/match/type/match'; + +describe('matchKeys', () => { + const id: GUID = '44444444-4444-4444-4444-444444444444'; + + it('list() returns the bare list literal with no trailing undefined', () => { + expect(matchKeys.list()).toEqual(['match', 'list']); + }); + + it('list(filter) returns the filtered list literal', () => { + const filter: MatchFiltered = { pageNumber: 1 }; + expect(matchKeys.list(filter)).toEqual(['match', 'list', filter]); + }); + + it('byId(id) returns the by-id literal', () => { + expect(matchKeys.byId(id)).toEqual(['match', 'byId', id]); + }); +}); diff --git a/Club12-WebClient/src/modules/match/queryKeys.ts b/Club12-WebClient/src/modules/match/queryKeys.ts new file mode 100644 index 0000000..2ec81eb --- /dev/null +++ b/Club12-WebClient/src/modules/match/queryKeys.ts @@ -0,0 +1,10 @@ +import { MatchFiltered } from '@/modules/match/type/match'; + +export const matchKeys = { + list: (filter?: MatchFiltered) => + filter === undefined + ? (['match', 'list'] as const) + : (['match', 'list', filter] as const), + byId: (id: string) => ['match', 'byId', id] as const, + byRound: (stageId: string) => ['match', 'byRound', stageId] as const, +}; diff --git a/Club12-WebClient/src/modules/match/service/match.service.test.ts b/Club12-WebClient/src/modules/match/service/match.service.test.ts new file mode 100644 index 0000000..05be6f2 --- /dev/null +++ b/Club12-WebClient/src/modules/match/service/match.service.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { + ILoadWalkOverRequest, + ISuspendMatchRequest, +} from '@/modules/match/type/match'; + +vi.mock('@/modules/core/utils/axiosUtils', () => ({ + sendGet: vi.fn(() => Promise.resolve({ data: [] })), + sendPost: vi.fn(), + sendPut: vi.fn(() => Promise.resolve({ data: {} })), + sendDelete: vi.fn(), +})); + +import { sendGet, sendPut } from '@/modules/core/utils/axiosUtils'; +import { matchService } from '@/modules/match/service/match.service'; + +const guid = (value: string) => value as GUID; + +describe('matchService.loadWalkOver', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('PUTs the present team to matches/{id}/walkover', async () => { + const matchId = guid('aaaa-bbbb-cccc-dddd-eeee'); + const request: ILoadWalkOverRequest = { + presentTeamId: guid('1111-2222-3333-4444-5555'), + }; + + await matchService.loadWalkOver(matchId, request); + + expect(sendPut).toHaveBeenCalledWith( + `matches/${matchId}/walkover`, + request + ); + }); +}); + +describe('matchService.suspendMatch', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('PUTs the optional new date to matches/{id}/suspend (HU-68)', async () => { + const matchId = guid('aaaa-bbbb-cccc-dddd-eeee'); + const request: ISuspendMatchRequest = { + matchDate: '2026-05-01T18:00:00.000Z', + }; + + await matchService.suspendMatch(matchId, request); + + expect(sendPut).toHaveBeenCalledWith( + `matches/${matchId}/suspend`, + request + ); + }); + + it('suspends in place when no new date is provided', async () => { + const matchId = guid('ffff-gggg-hhhh-iiii-jjjj'); + + await matchService.suspendMatch(matchId, {}); + + expect(sendPut).toHaveBeenCalledWith(`matches/${matchId}/suspend`, {}); + }); +}); + +describe('matchService.getStageMatchesByRound', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('GETs the jornada-grouped fixture from matches/stage/{stageId}/by-round (HU-63)', async () => { + const stageId = guid('1111-2222-3333-4444-5555'); + + await matchService.getStageMatchesByRound(stageId); + + expect(sendGet).toHaveBeenCalledWith( + `matches/stage/${stageId}/by-round` + ); + }); +}); diff --git a/Club12-WebClient/src/modules/match/service/match.service.ts b/Club12-WebClient/src/modules/match/service/match.service.ts new file mode 100644 index 0000000..26a7d77 --- /dev/null +++ b/Club12-WebClient/src/modules/match/service/match.service.ts @@ -0,0 +1,156 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + IAddMatchRequest, + ILoadWalkOverRequest, + MatchFiltered, + IMatchResponse, + IMinimalMatchResponse, + IPutMatchRequest, + IPutMatchResultFromSheetsRequest, + IPutMatchScoreRequest, + IRoundMatchesResponse, + ISuspendMatchRequest, +} from '@/modules/match/type/match'; + +/** + * MatchService provides methods to interact with the matches API. + */ +export const matchService = { + /** + * Adds a new match. + * @param {IAddMatchRequest} match - The match data to be added. + * @returns {Promise>} - A promise that resolves with the server response. + */ + addMatch: async ( + match: IAddMatchRequest + ): Promise> => + sendPost(routes.matches, match), + + /** + * Updates the score of an existing match. + * @param {string} id - The ID of the match to update. + * @param {IPutMatchScoreRequest} matchScore - The new match score data. + * @returns {Promise>} - A promise that resolves with the server response. + */ + putMatchScoreByMatchId: async ( + id: GUID, + matchScore: IPutMatchScoreRequest + ): Promise> => + sendPut(`${routes.matches}/${id}/score`, matchScore), + + /** + * Finishes a match by loading both teams' scoring sheets in one operation + * (HU-72): the final score is derived as the sum of each team's listed + * player points. + * @param {string} id - The ID of the match to finish. + * @param {IPutMatchResultFromSheetsRequest} request - Both teams' per-player points. + * @returns {Promise>} - A promise that resolves with the server response. + */ + loadMatchResultFromSheets: async ( + id: GUID, + request: IPutMatchResultFromSheetsRequest + ): Promise> => + sendPut(`${routes.matches}/${id}/result-from-sheets`, request), + + /** + * Updates the date of an existing match. + * @param {string} id - The ID of the match to update. + * @param {IPutMatchRequest} matchDate - The new match date data. + * @returns {Promise>} - A promise that resolves with the server response. + */ + putMatchByMatchId: async ( + id: GUID, + matchDate: IPutMatchRequest + ): Promise> => + sendPut(`${routes.matches}/${id}`, matchDate), + + /** + * Marks a match as a walkover (HU-73), awarding the regulation default + * result to the present team. + * @param {string} id - The ID of the match to mark as a walkover. + * @param {ILoadWalkOverRequest} request - The present team (and optional score override). + * @returns {Promise>} - A promise that resolves with the updated match. + */ + loadWalkOver: async ( + id: GUID, + request: ILoadWalkOverRequest + ): Promise> => + sendPut(`${routes.matches}/${id}/walkover`, request), + + /** + * Retrieves a match by its ID or its public slug. + * @param {string} idOrSlug - The ID or slug of the match to retrieve. + * @returns {Promise>} - A promise that resolves with the match data. + */ + getMatchById: async ( + idOrSlug: string + ): Promise> => + sendGet(`${routes.matches}/${idOrSlug}`), + + /** + * Retrieves matches based on the provided filter. + * @param {MatchFiltered} filter - The filter to apply when retrieving matches. + * @returns {Promise>>} - A promise that resolves with a list of matches matching the filter. + */ + getMatchByFilter: async ( + filter: MatchFiltered + ): Promise>> => + sendGet>( + routes.matches, + withTablePageSize(filter) + ), + + /** + * Retrieves a stage's matches grouped and ordered by matchday (jornada, + * HU-63) so the fixture renders as "Fecha 1 / Partido 1..2, Fecha 2 / …". + * @param {GUID} stageId - The ID of the stage whose fixture is requested. + * @returns {Promise>} - A promise that resolves with the rounds. + */ + getStageMatchesByRound: async ( + stageId: GUID + ): Promise> => + sendGet( + `${routes.matches}/stage/${stageId}/by-round` + ), + + /** + * Reprograms/suspends a match (HU-68): marks it suspended and optionally + * moves it to a new date, without changing its round (HU-67). + * @param {GUID} id - The ID of the match to suspend/reprogram. + * @param {ISuspendMatchRequest} request - The optional new date. + * @returns {Promise>} - A promise that resolves with the updated match. + */ + suspendMatch: async ( + id: GUID, + request: ISuspendMatchRequest + ): Promise> => + sendPut(`${routes.matches}/${id}/suspend`, request), + + /** + * Deletes a match by its ID. + * @param {string} id - The ID of the match to delete. + * @returns {Promise>} - A promise that resolves when the match is deleted. + */ + deleteMatchById: async (id: GUID): Promise> => + sendDelete(`${routes.matches}/${id}`), + + /** + * Automatically generates matches for a stage (e.g. a round-robin group + * fixture, or the empty slots of an elimination round). + * @param {string} id - The ID of the stage to generate matches for. + * @returns {Promise>} - A promise that resolves with the generated matches. + */ + generateMatches: async ( + id: GUID + ): Promise> => + sendPost(`${routes.matches}/generate/${id}`), +}; diff --git a/Club12-WebClient/src/modules/match/type/match.d.ts b/Club12-WebClient/src/modules/match/type/match.d.ts new file mode 100644 index 0000000..ec0febd --- /dev/null +++ b/Club12-WebClient/src/modules/match/type/match.d.ts @@ -0,0 +1,513 @@ +import { Filtered, GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { MatchType } from '@/modules/core/enum/match/matchType'; +import { MatchStatus } from '@/modules/core/enum/match/matchStatus'; +import { ITeamMatchResponse } from '@/modules/team/type/team'; +import { IVenueResponse } from '@/modules/venue/type/venue'; +import { PlayerScoreEntry } from '@/modules/playerStatistic/type/playerStatistic'; + +/** + * Context properties and methods for managing matches in a sports system. + * These methods interact with the backend for creating, updating, fetching, and deleting matches. + * @interface IMatchContextProps + */ +export interface IMatchContextProps { + match: IMatchResponse | null; + matches: IMatchResponse[] | null; + /** + * Adds a new match to the system. + * @param match The details of the match to add. + * @returns A promise that resolves with the response containing the newly added match. + */ + addMatch(match: IAddMatchRequest): Promise; + + /** + * Updates the score of an existing match. + * @param id The ID of the match . + * @param matchScore The new scores for the home and visitor teams. + * @returns A promise that resolves when the score is successfully updated. + */ + putMatchScoreByMatchId( + id: GUID, + matchScore: IPutMatchScoreRequest + ): Promise; + + /** + * Finishes a match by loading both teams' scoring sheets in one operation + * (HU-72): the final score is derived as the sum of each team's listed + * player points, instead of typing the score in separately. + * @param id The ID of the match. + * @param request Both teams' per-player points. + * @returns A promise that resolves with the finalized match. + */ + loadMatchResultFromSheets( + id: GUID, + request: IPutMatchResultFromSheetsRequest + ): Promise; + + /** + * Updates the match date and venue. + * @param id The ID of the match . + * @param matchDate The new match date. + * @returns A promise that resolves when the match date and venue are successfully updated. + */ + putMatchByMatchId( + id: GUID, + matchDate: IPutMatchRequest + ): Promise; + + /** + * Fetches a match by its ID or its public slug. + * @param idOrSlug The ID or slug of the match to fetch. + * @returns A promise that resolves with the match details. + */ + getMatchById(idOrSlug: string): Promise; + + /** + * Fetches matches based on filters and pagination. + * @param filter The filter criteria to apply when fetching matches. + * @returns A promise that resolves with a paginated response containing filtered matches. + */ + getMatchByFilter( + filter: MatchFiltered + ): Promise | void>; + + /** + * Fetches a stage's matches grouped and ordered by matchday (jornada, HU-63) + * so the fixture can be rendered as "Fecha 1 / Partido 1..2, Fecha 2 / …". + * @param stageId The id of the stage whose fixture is requested. + * @returns A promise that resolves with the rounds, or void on error. + */ + getStageMatchesByRound( + stageId: GUID + ): Promise; + + /** + * Reprograms/suspends a match (HU-68): marks it suspended and optionally + * moves it to a new date, without changing its round (HU-67). + * @param id The id of the match to suspend/reprogram. + * @param request The optional new date. + * @returns A promise that resolves with the updated match, or void on error. + */ + suspendMatch( + id: GUID, + request: ISuspendMatchRequest + ): Promise; + + /** + * Marks a match as a walkover (HU-73), awarding the regulation default + * result to the present team. + * @param id The ID of the match to mark as a walkover. + * @param request The present team (and optional score override). + * @returns A promise that resolves with the updated match, or void on error. + */ + loadWalkOver( + id: GUID, + request: ILoadWalkOverRequest + ): Promise; + + /** + * Deletes a match by its ID. + * @param id The ID of the match to delete. + * @returns A promise that resolves when the match is successfully deleted. + */ + deleteMatchById(id: GUID): Promise; + + /** + * Automatically generates matches for the specified division or tournament. + * + * @param {GUID} id - The unique identifier of the stage for which the stages will be generated. + * @returns {Promise} A promise that resolves to true if the stages were successfully generated, or false otherwise. + */ + generateMatchesAutomatically(id: GUID): Promise; +} + +/** + * The request body structure for adding a new match. + * @interface AddMatchRequest + */ +export interface IAddMatchRequest { + /** + * The date and time of the match. + * @type {string} + */ + matchDate: string; + + /** + * The type of match (e.g., Regular, Playoff). + * @type {MatchType} + */ + type?: MatchType; + + /** + * The ID of the home team. + * @type {string} + */ + homeTeamId: GUID; + + /** + * The ID of the visitor team. + * @type {string} + */ + visitorTeamId: GUID; + + /** + * The ID of the division the match belongs to. + * @type {GUID} + */ + stageId: GUID; + + /** + * The ID of the venue where the match will take place. + * @type {GUID} + */ + venueId?: GUID; +} + +/** + * + */ +export interface MatchFormProps { + errors: string[] | null; + startDate: string; + endDate: string; + form: T; + setForm: Dispatch>; +} + +/** + * @interface IMatchResponse + * @description The response structure for a match, including team details, scores, and match results. + */ +export interface IMatchResponse { + /** + * @property {GUID} id - The unique identifier of the match. + */ + id: GUID; + + /** + * @property {string} matchDate - The date and time when the match took place. + */ + matchDate: string; + + /** + * @property {number | null} round - The matchday (jornada) this match belongs + * to, 1-based (HU-63/HU-65). This is the canonical grouping key for the + * fixture ("Fecha 1", "Fecha 2", …); the UI groups by this rather than by + * the calendar date (HU-63). Null for matches with no round-robin matchday + * (e.g. knockout stages). + */ + round?: number | null; + + /** + * @property {MatchType} matchType - The category or type of the match (e.g., Regular Season, Playoff). + */ + matchType: MatchType; + + /** + * @property {string} slug - The unique, URL-friendly identifier used in public match links. + */ + slug: string; + + /** + * @property {ITeamMatchResponse} homeTeam - Details of the home team participating in the match. + */ + homeTeam: ITeamMatchResponse | null; + + /** + * @property {ITeamMatchResponse} visitorTeam - Details of the visiting team participating in the match. + */ + visitorTeam: ITeamMatchResponse | null; + + /** + * @property {boolean} isFinished - A boolean indicating whether the match has concluded. + */ + isFinished: boolean; + + /** Whether the match was decided in overtime (basketball rule). */ + wentToOvertime?: boolean; + + /** + * @property {GUID | null} winningTeamId - The unique identifier (GUID) of the team that won the match, or null if the match is not finished or was a draw. + */ + winningTeamId: GUID | null; + + /** + * @property {IVenueResponse} venue - Details about the venue where the match was played. + */ + venue: IVenueResponse | null; + + /** + * @property {GUID | null} stageId - The unique identifier of the stage to which the match belongs, or null if not assigned. + */ + stageId: GUID | null; + + /** + * @property {GUID | null} tournamentId - The tournament this match belongs + * to (via its stage's division), used to navigate back to the tournament + * from the public match page. Null for a match with no stage assigned yet. + */ + tournamentId?: GUID | null; + + /** + * @property {string | null} winningTeamName - The name of the winning team, or null if the match is not finished or was a draw. + */ + winningTeamName: string | null; + + /** + * @property {MatchStatus | null} status - The lifecycle status of the match + * (Scheduled/Played/Suspended/WalkOver). Lets a walkover be told apart from + * a normal result. Optional/null when the backend did not populate it. + */ + status?: MatchStatus | null; +} + +/** + * The minimal response structure for a match, tailored for divisions + * (e.g. the create-match endpoint and a division's week schedule). + * Unlike {@link IMatchResponse}, teams are flat name strings rather than + * nested team objects. + * @interface IMinimalMatchResponse + */ +export interface IMinimalMatchResponse { + id: GUID; + matchDate: string; + homeTeamName: string; + visitorTeamName: string; + homeScore: number | null; + visitorScore: number | null; + winningTeamName: string | null; + isFinished: boolean; + matchType: MatchType; + status?: MatchStatus | null; + + /** + * The matchday (jornada) this match belongs to, 1-based (HU-63/HU-65). Null + * for matches with no round-robin matchday (e.g. knockout stages). + * @type {number | null} + */ + round?: number | null; +} + +/** + * A single matchday (jornada) and the matches played in it (HU-63). Mirrors the + * backend `RoundMatchesResponse`; returned ordered so the fixture can be + * rendered grouped by round ("Fecha 1 / Partido 1..2, Fecha 2 / …") instead of + * by calendar date. + * @interface IRoundMatchesResponse + */ +export interface IRoundMatchesResponse { + /** + * The 1-based round number. Null groups matches with no round-robin matchday + * (e.g. knockout stages). + * @type {number | null} + */ + round: number | null; + + /** + * The matches played in this round, in a stable order. + * @type {IMatchResponse[]} + */ + matches: IMatchResponse[]; +} + +/** + * The request body for reprogramming/suspending a match (HU-68). The match is + * marked suspended and, when a new date is provided, moved to it — never + * changing its round (HU-67). + * @interface ISuspendMatchRequest + */ +export interface ISuspendMatchRequest { + /** + * Optional new calendar date/time (ISO 8601). When omitted, the match is + * suspended in place without rescheduling. + * @type {string} + */ + matchDate?: string; +} + +/** + * The filter criteria for fetching matches, which includes the home and visitor team names, division name, match type, and finish status. + * @interface MatchFiltered + * @extends Filtered + */ +export interface MatchFiltered extends Filtered { + /** + * The id of the tournament the match belongs to. + * @type {GUID} + */ + tournamentId?: GUID; + + /** + * The id of the division the match belongs to. + * @type {GUID} + */ + divisionId?: GUID; + + /** + * The name of the home team. + * @type {string} + */ + homeTeamName?: string; + + /** + * The name of the visitor team. + * @type {string} + */ + visitorTeamName?: string; + + /** + * The id of the stage the match belongs to. + * @type {GUID} + */ + stageId?: GUID; + + /** + * The type of match (Regular or Playoff). + * @type {MatchType} + */ + type?: MatchType; + + /** + * Indicates whether the match is finished. + * @type {boolean} + */ + isFinished?: boolean; +} + +/** + * The request body structure for updating the score of a match. + * @interface IPutMatchScoreRequest + */ +export interface IPutMatchScoreRequest { + /** + * The new score for the home team. + * @type {number} + */ + homeScore: number; + + /** + * The new score for the visitor team. + * @type {number} + */ + visitorScore: number; +} + +/** + * The request body structure for finishing a match by deriving its score + * from both teams' scoring sheets (HU-72). + * @interface IPutMatchResultFromSheetsRequest + */ +export interface IPutMatchResultFromSheetsRequest { + /** + * The home team's per-player points. + * @type {PlayerScoreEntry[]} + */ + homeScores: PlayerScoreEntry[]; + + /** + * The visitor team's per-player points. + * @type {PlayerScoreEntry[]} + */ + visitorScores: PlayerScoreEntry[]; + + /** Whether the match was decided in overtime (basketball rule). */ + wentToOvertime: boolean; +} + +/** + * The request body structure for updating the date, venue and teams of a match. + * @interface IPutMatchRequest + */ +export interface IPutMatchRequest { + /** + * The new match date. + * @type {string} + */ + matchDate?: string; + + /** + * The id of the venue where the match will be played. + * @type {GUID} + */ + venueId?: GUID; + + /** + * The ID of the home team. + * @type {GUID} + */ + homeTeamId?: GUID; + + /** + * The ID of the visitor team. + * @type {GUID} + */ + visitorTeamId?: GUID; +} + +/** + * @interface IMatchStatusChipProps + * @description Props for a component that displays the status of a match, + * such as upcoming, in-progress, or finished. + */ +export interface IMatchStatusChipProps { + /** + * @property {string} startTime - The start time of the match, typically in ISO 8601 format. + */ + startTime: string; + + /** + * @property {boolean} isFinished - A boolean indicating whether the match has concluded. + */ + isFinished: boolean; + + /** + * @property {number} [maxMinutes] - An optional property representing the maximum duration + * of the match in minutes. Useful for calculating remaining time or progress. + */ + maxMinutes?: number; +} + +export interface IEditMatch extends IPutMatchRequest { + id: GUID; + homeScore: number; + visitorScore: number; + matchDate: Date; + isFinished: boolean; + venue: IVenueResponse | null; + + /** + * The start date of the stage. + * @type {string} ISO 8601 format date. + */ + startDate: string; + + /** + * The end date of the stage. + * @type {string} ISO 8601 format date. + */ + endDate: string; +} + +export interface IDashboardMatches { + matches: IMatchResponse[] | null; +} + +/** + * The request body structure for marking a match as a walkover (HU-73). + * @interface ILoadWalkOverRequest + */ +export interface ILoadWalkOverRequest { + /** + * The team that showed up (the walkover winner). Must be one of the match's + * two teams. + * @type {GUID} + */ + presentTeamId: GUID; + + /** + * Optional override for the present team's awarded score. When omitted, the + * backend applies the regulation default. + * @type {number} + */ + presentTeamScore?: number; +} diff --git a/Club12-WebClient/src/modules/match/utils/divisionFixtureSections.test.ts b/Club12-WebClient/src/modules/match/utils/divisionFixtureSections.test.ts new file mode 100644 index 0000000..a1858d4 --- /dev/null +++ b/Club12-WebClient/src/modules/match/utils/divisionFixtureSections.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { MatchType } from '@/modules/core/enum/match/matchType'; +import { IMatchResponse } from '@/modules/match/type/match'; +import { ITeamMatchResponse } from '@/modules/team/type/team'; +import { IStageResponse, StageType } from '@/modules/stage/type/stage'; +import { + buildDivisionFixtureSections, + groupFixtureSectionsByBracket, +} from '@/modules/match/utils/divisionFixtureSections'; + +const guid = (value: string) => value as GUID; + +const team = (name: string): ITeamMatchResponse => ({ + id: guid(`team-${name}`), + name, + logoUrl: '', + score: 0, + players: [], + scorers: [], +}); + +let sequence = 0; + +const match = (stageId: string, overrides: Partial = {}): IMatchResponse => ({ + id: guid(`match-${(sequence += 1)}`), + matchDate: '2026-04-28T20:00:00Z', + round: 1, + matchType: MatchType.Regular, + slug: `match-${sequence}`, + homeTeam: team('A'), + visitorTeam: team('B'), + isFinished: false, + winningTeamId: null, + venue: null, + stageId: guid(stageId), + winningTeamName: null, + status: null, + ...overrides, +}); + +const stage = ( + overrides: Partial> & { id: string; name: string } +): IStageResponse => ({ + slug: `stage-${overrides.id}`, + description: null, + stageType: StageType.Group, + isActive: true, + isElimination: false, + startDate: '2026-04-01T00:00:00Z', + endDate: '2026-05-01T00:00:00Z', + divisionId: guid('division-1'), + order: 0, + bracketName: null, + bestOf: 1, + roundRobinLegs: 1, + ...overrides, + id: guid(overrides.id), +}); + +describe('buildDivisionFixtureSections', () => { + it('labels a stage by its specific part, stripping the "{Division} - " prefix, ordered by order', () => { + const stages = [ + stage({ id: 'final', name: 'Copa Club12 - Final', stageType: StageType.Final, order: 2 }), + stage({ id: 'zona', name: 'Copa Club12 - ZONA 3', stageType: StageType.Group, order: 1 }), + ]; + const matches = [match('zona'), match('final')]; + + const sections = buildDivisionFixtureSections(stages, matches, 'Copa Club12'); + + expect(sections.map(s => s.label)).toEqual(['ZONA 3', 'Final']); + expect(sections[0].stage.id).toBe('zona'); + expect(sections[0].matches).toHaveLength(1); + expect(sections[1].matches).toHaveLength(1); + }); + + it('labels each parallel group stage by its own name when there is more than one Group stage', () => { + const stages = [ + stage({ id: 'g1', name: 'Grupo 1', stageType: StageType.Group, order: 1 }), + stage({ id: 'g2', name: 'Grupo 2', stageType: StageType.Group, order: 2 }), + ]; + const matches = [match('g1'), match('g2')]; + + const sections = buildDivisionFixtureSections(stages, matches, 'Copa Club12'); + + expect(sections.map(s => s.label)).toEqual(['Grupo 1', 'Grupo 2']); + }); + + it('orders Semifinal, ThirdPlace, then Final by phase even when every stage ties on order — alphabetically "Final" would sort before "Semifinal" and "Tercer Puesto"', () => { + const stages = [ + stage({ id: 'final', name: 'Copa B - Final', stageType: StageType.Final, order: 0 }), + stage({ id: 'semi', name: 'Copa B - Semifinal', stageType: StageType.SemiFinal, order: 0 }), + stage({ id: 'third', name: 'Copa B - Tercer Puesto', stageType: StageType.ThirdPlace, order: 0 }), + ]; + const matches = [match('final'), match('semi'), match('third')]; + + const sections = buildDivisionFixtureSections(stages, matches, 'Copa B'); + + expect(sections.map(s => s.stage.id)).toEqual(['semi', 'third', 'final']); + }); + + it('drops stages that have no matches (empty-section filtering)', () => { + const stages = [ + stage({ id: 'played', name: 'Copa Club12 - ZONA 1', stageType: StageType.Group, order: 1 }), + stage({ id: 'empty', name: 'Copa Club12 - ZONA 2', stageType: StageType.Group, order: 2 }), + ]; + const matches = [match('played')]; + + const sections = buildDivisionFixtureSections(stages, matches, 'Copa Club12'); + + expect(sections).toHaveLength(1); + expect(sections[0].stage.id).toBe('played'); + }); +}); + +describe('groupFixtureSectionsByBracket', () => { + it('groups a two-cup playoff by bracket, stripping the now-redundant bracket prefix from each round label', () => { + const stages = [ + stage({ id: 'oro-semi', name: 'Semifinales Copa Oro', stageType: StageType.SemiFinal, bracketName: 'Copa Oro', order: 1 }), + stage({ id: 'oro-final', name: 'Final Copa Oro', stageType: StageType.Final, bracketName: 'Copa Oro', order: 2 }), + stage({ id: 'plata-semi', name: 'Semifinales Copa Plata', stageType: StageType.SemiFinal, bracketName: 'Copa Plata', order: 3 }), + stage({ id: 'plata-final', name: 'Final Copa Plata', stageType: StageType.Final, bracketName: 'Copa Plata', order: 4 }), + ]; + const matches = [match('oro-semi'), match('oro-final'), match('plata-semi'), match('plata-final')]; + + const sections = buildDivisionFixtureSections(stages, matches, 'Zona A'); + const groups = groupFixtureSectionsByBracket(sections); + + expect(groups.map(g => g.bracketName)).toEqual(['Copa Oro', 'Copa Plata']); + expect(groups[0].sections.map(s => s.label)).toEqual(['Semifinal', 'Final']); + expect(groups[1].sections.map(s => s.label)).toEqual(['Semifinal', 'Final']); + }); + + it('keeps the full "{bracket} — {round}" label for a division with a single, unnamed bracket (no group header to carry that context instead)', () => { + const stages = [ + stage({ id: 'semi', name: 'Zona A - Semifinal', stageType: StageType.SemiFinal, order: 1 }), + stage({ id: 'final', name: 'Zona A - Final', stageType: StageType.Final, order: 2 }), + ]; + const matches = [match('semi'), match('final')]; + + const sections = buildDivisionFixtureSections(stages, matches, 'Zona A'); + const groups = groupFixtureSectionsByBracket(sections); + + expect(groups).toHaveLength(1); + expect(groups[0].bracketName).toBeNull(); + expect(groups[0].sections.map(s => s.label)).toEqual(['Semifinal', 'Final']); + }); +}); diff --git a/Club12-WebClient/src/modules/match/utils/divisionFixtureSections.ts b/Club12-WebClient/src/modules/match/utils/divisionFixtureSections.ts new file mode 100644 index 0000000..3031a78 --- /dev/null +++ b/Club12-WebClient/src/modules/match/utils/divisionFixtureSections.ts @@ -0,0 +1,117 @@ +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { IStageResponse, StageType } from '@/modules/stage/type/stage'; +import { stageLabel } from '@/modules/stage/utils/stageLabel'; +import { translateStageType } from '@/modules/core/utils/translateStageType'; + +const STAGE_NAME_DIVISION_SEPARATOR = ' - '; + +/** Canonical chronological order of stage types, checked ahead of a stage's own `order` field. */ +const STAGE_TYPE_ORDER: Partial> = { + [StageType.Group]: 0, + [StageType.RoundOf16]: 1, + [StageType.QuarterFinal]: 2, + [StageType.SemiFinal]: 3, + [StageType.ThirdPlace]: 4, + [StageType.Final]: 5, +}; + +/** + * Stage names follow a "{Division} - {Specific}" convention (e.g. + * "Copa Club12 - ZONA 3"). We're already inside that division's tab, so + * strip the redundant prefix and show the specific part — this is what + * distinguishes stages sharing the same type (e.g. a cup division with + * several parallel group stages, all "Group" type with no bracketName, + * which stageLabel() alone can't tell apart). + */ +export const stageSectionLabel = (stage: IStageResponse, divisionName: string): string => { + const prefix = `${divisionName}${STAGE_NAME_DIVISION_SEPARATOR}`; + return stage.name.startsWith(prefix) ? stage.name.slice(prefix.length) : stageLabel(stage); +}; + +export interface DivisionFixtureSection { + stage: IStageResponse; + label: string; + matches: IMatchResponse[]; +} + +/** + * Groups a division's matches into ordered, labelled fixture sections, one per stage that has at + * least one match, empty sections dropped. + * + * A multi-group cross-division cup has several parallel Group stages + * ("Grupo 1".."Grupo N"). stageSectionLabel would collapse them all to the + * generic "Fase de grupos", so each is labelled by its own stage name instead — + * that is the only thing distinguishing one group's fixture from another. + */ +export const buildDivisionFixtureSections = ( + stages: IStageResponse[], + matches: IMatchResponse[], + divisionName: string +): DivisionFixtureSection[] => { + const stagesInOrder = [...stages].sort((a, b) => { + const typeOrderA = STAGE_TYPE_ORDER[a.stageType] ?? Number.MAX_SAFE_INTEGER; + const typeOrderB = STAGE_TYPE_ORDER[b.stageType] ?? Number.MAX_SAFE_INTEGER; + return ( + typeOrderA - typeOrderB || + a.order - b.order || + a.name.localeCompare(b.name, 'es', { numeric: true }) + ); + }); + const groupStageCount = stages.filter(stage => stage.stageType === StageType.Group).length; + return stagesInOrder + .map(stage => { + const isDistinctGroup = stage.stageType === StageType.Group && groupStageCount > 1; + return { + stage, + label: isDistinctGroup ? stage.name : stageSectionLabel(stage, divisionName), + matches: matches.filter(match => match.stageId === stage.id), + }; + }) + .filter(section => section.matches.length > 0); +}; + +/** One cup's (or the default bracket's) fixture sections, grouped together. */ +export interface BracketFixtureGroup { + /** Null for a division with a single, unnamed bracket (no sub-cups). */ + bracketName: string | null; + sections: DivisionFixtureSection[]; +} + +/** + * Groups a playoff division's fixture sections by their cup (Copa Oro, Copa + * Plata, …) so each cup renders as its own clearly separated block instead + * of every round from every cup reading as one flat, undifferentiated list — + * a division with two cups round-by-round otherwise interleaves as + * "Semifinal (Oro)", "Semifinal (Plata)", "Final (Oro)", "Final (Plata)" + * with nothing visually tying each cup's own rounds together. + * + * Once a cup has its own group header, repeating its name on every one of + * its rounds is noise (the same lesson as not repeating "Fase final" on + * every knockout round) — each section's label is stripped down to just the + * round name for a named bracket, keeping the full "{bracket} — {round}" + * label only for the unnamed default bracket, which has no group header of + * its own to carry that context instead. + */ +export const groupFixtureSectionsByBracket = ( + sections: DivisionFixtureSection[] +): BracketFixtureGroup[] => { + const groups: BracketFixtureGroup[] = []; + const groupIndexByBracket = new Map(); + + sections.forEach(section => { + const bracketName = section.stage.bracketName ?? null; + const label = bracketName ? translateStageType(section.stage.stageType) : section.label; + const relabelled: DivisionFixtureSection = { ...section, label }; + + const existingIndex = groupIndexByBracket.get(bracketName); + if (existingIndex !== undefined) { + groups[existingIndex].sections.push(relabelled); + return; + } + + groupIndexByBracket.set(bracketName, groups.length); + groups.push({ bracketName, sections: [relabelled] }); + }); + + return groups; +}; diff --git a/Club12-WebClient/src/modules/match/utils/matchDisplay.test.ts b/Club12-WebClient/src/modules/match/utils/matchDisplay.test.ts new file mode 100644 index 0000000..3e383b6 --- /dev/null +++ b/Club12-WebClient/src/modules/match/utils/matchDisplay.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { MatchStatus } from '@/modules/core/enum/match/matchStatus'; +import { + getMatchStatusBadgeColor, + getMatchStatusBadgeLabel, + getScoreboardEmphasis, + resolveMatchStatus, + sortScorersByPoints, +} from '@/modules/match/utils/matchDisplay'; + +describe('matchDisplay status badge helpers', () => { + it('maps each status to its Spanish label', () => { + expect(getMatchStatusBadgeLabel(MatchStatus.Scheduled, false)).toBe( + 'Programado' + ); + expect(getMatchStatusBadgeLabel(MatchStatus.Played, true)).toBe('Jugado'); + expect(getMatchStatusBadgeLabel(MatchStatus.Suspended, false)).toBe( + 'Suspendido' + ); + expect(getMatchStatusBadgeLabel(MatchStatus.WalkOver, true)).toBe('W.O.'); + }); + + it('maps each status to a distinct chip color', () => { + expect(getMatchStatusBadgeColor(MatchStatus.Scheduled, false)).toBe( + 'default' + ); + expect(getMatchStatusBadgeColor(MatchStatus.Played, true)).toBe('success'); + expect(getMatchStatusBadgeColor(MatchStatus.Suspended, false)).toBe( + 'warning' + ); + expect(getMatchStatusBadgeColor(MatchStatus.WalkOver, true)).toBe('info'); + }); + + it('falls back to isFinished when status is missing', () => { + expect(resolveMatchStatus(null, true)).toBe(MatchStatus.Played); + expect(resolveMatchStatus(undefined, false)).toBe(MatchStatus.Scheduled); + // An explicit status always wins over the isFinished fallback. + expect(resolveMatchStatus(MatchStatus.WalkOver, false)).toBe( + MatchStatus.WalkOver + ); + }); +}); + +describe('getScoreboardEmphasis', () => { + it('emphasises the home side when it is the winning team', () => { + expect( + getScoreboardEmphasis({ + isFinished: true, + homeTeamId: 'home', + visitorTeamId: 'visitor', + winningTeamId: 'home', + }) + ).toEqual({ home: 'winner', visitor: 'loser' }); + }); + + it('emphasises the visitor side when it is the winning team', () => { + expect( + getScoreboardEmphasis({ + isFinished: true, + homeTeamId: 'home', + visitorTeamId: 'visitor', + winningTeamId: 'visitor', + }) + ).toEqual({ home: 'loser', visitor: 'winner' }); + }); + + it('stays neutral while the match is not finished, even if a winner id exists', () => { + expect( + getScoreboardEmphasis({ + isFinished: false, + homeTeamId: 'home', + visitorTeamId: 'visitor', + winningTeamId: 'home', + }) + ).toEqual({ home: 'neutral', visitor: 'neutral' }); + }); + + it('stays neutral when the match is finished but has no recorded winner', () => { + expect( + getScoreboardEmphasis({ + isFinished: true, + homeTeamId: 'home', + visitorTeamId: 'visitor', + winningTeamId: null, + }) + ).toEqual({ home: 'neutral', visitor: 'neutral' }); + }); +}); + +describe('sortScorersByPoints', () => { + it('orders scorers by points descending, then by name, without mutating the input', () => { + const scorers = [ + { fullName: 'Ana', points: 10 }, + { fullName: 'Beto', points: 25 }, + { fullName: 'Carla', points: 10 }, + ]; + + const sorted = sortScorersByPoints(scorers); + + expect(sorted.map(s => s.fullName)).toEqual(['Beto', 'Ana', 'Carla']); + // The original array is left untouched (pure). + expect(scorers[0].fullName).toBe('Ana'); + }); +}); diff --git a/Club12-WebClient/src/modules/match/utils/matchDisplay.ts b/Club12-WebClient/src/modules/match/utils/matchDisplay.ts new file mode 100644 index 0000000..e1ad766 --- /dev/null +++ b/Club12-WebClient/src/modules/match/utils/matchDisplay.ts @@ -0,0 +1,120 @@ +import { MatchStatus } from '@/modules/core/enum/match/matchStatus'; + +export const MATCH_FINISHED_LABEL = 'Finalizado'; +export const MATCH_SCHEDULED_LABEL = 'Programado'; + +export const MATCH_FINISHED_COLOR = 'success' as const; +export const MATCH_SCHEDULED_COLOR = 'default' as const; + +export const getMatchStatusLabel = (finished: boolean): string => + finished ? MATCH_FINISHED_LABEL : MATCH_SCHEDULED_LABEL; + +export const getMatchStatusColor = (finished: boolean) => + finished ? MATCH_FINISHED_COLOR : MATCH_SCHEDULED_COLOR; + +const WALKOVER_LABEL = 'W.O.'; +const WALKOVER_WINNING_SCORE = 20; +const WALKOVER_LOSING_SCORE = 0; + +/** + * The league's walkover (forfeit) convention: the no-show team is recorded + * as 0, the present team as 20. No team can actually score 0 in a played + * game, so this pattern is an unambiguous signal, not a real scoreline. + */ +export const isWalkoverScore = (homeScore: number, visitorScore: number): boolean => + (homeScore === WALKOVER_WINNING_SCORE && visitorScore === WALKOVER_LOSING_SCORE) || + (homeScore === WALKOVER_LOSING_SCORE && visitorScore === WALKOVER_WINNING_SCORE); + +export const formatMatchScore = (homeScore: number, visitorScore: number): string => + isWalkoverScore(homeScore, visitorScore) + ? WALKOVER_LABEL + : `${homeScore} – ${visitorScore}`; + +/** MUI Chip color for each match status. */ +type MatchStatusColor = 'default' | 'success' | 'warning' | 'info' | 'error'; + +const MATCH_STATUS_LABELS: Record = { + [MatchStatus.Scheduled]: 'Programado', + [MatchStatus.Played]: 'Jugado', + [MatchStatus.Suspended]: 'Suspendido', + [MatchStatus.WalkOver]: 'W.O.', + [MatchStatus.Canceled]: 'Cancelado', +}; + +const MATCH_STATUS_COLORS: Record = { + [MatchStatus.Scheduled]: 'default', + [MatchStatus.Played]: 'success', + [MatchStatus.Suspended]: 'warning', + [MatchStatus.WalkOver]: 'info', + [MatchStatus.Canceled]: 'error', +}; + +/** + * Resolves the effective match status, falling back to a value derived from + * `isFinished` when the backend did not populate `status` (older responses). + */ +export const resolveMatchStatus = ( + status: MatchStatus | null | undefined, + isFinished: boolean +): MatchStatus => + status ?? (isFinished ? MatchStatus.Played : MatchStatus.Scheduled); + +/** The Spanish label shown on the status badge. */ +export const getMatchStatusBadgeLabel = ( + status: MatchStatus | null | undefined, + isFinished: boolean +): string => MATCH_STATUS_LABELS[resolveMatchStatus(status, isFinished)]; + +/** The MUI Chip color for the status badge. */ +export const getMatchStatusBadgeColor = ( + status: MatchStatus | null | undefined, + isFinished: boolean +): MatchStatusColor => MATCH_STATUS_COLORS[resolveMatchStatus(status, isFinished)]; + +/** How prominently one side of the scoreboard should read. */ +export type ScoreEmphasis = 'winner' | 'loser' | 'neutral'; + +/** The emphasis to apply to each side of a scoreboard. */ +export interface ScoreboardEmphasis { + home: ScoreEmphasis; + visitor: ScoreEmphasis; +} + +/** + * Derives which side of the scoreboard is the winner (emphasised) and which is + * the loser (dimmed), from the match's recorded winner. A match that is not + * finished — or finished without a recorded winner — stays neutral on both + * sides, so no team is emphasised before there is a real result. The league + * plays on neutral venues (HU: no local/visita), so this is purely about the + * result, never home/away standing. + */ +export const getScoreboardEmphasis = (params: { + isFinished: boolean; + homeTeamId?: string | null; + visitorTeamId?: string | null; + winningTeamId?: string | null; +}): ScoreboardEmphasis => { + const { isFinished, homeTeamId, visitorTeamId, winningTeamId } = params; + + if (!isFinished || !winningTeamId) { + return { home: 'neutral', visitor: 'neutral' }; + } + if (winningTeamId === homeTeamId) { + return { home: 'winner', visitor: 'loser' }; + } + if (winningTeamId === visitorTeamId) { + return { home: 'loser', visitor: 'winner' }; + } + return { home: 'neutral', visitor: 'neutral' }; +}; + +/** + * Orders a team's match scorers for display: most points first, ties broken by + * name so the list is stable. Returns a new array — the input is never mutated. + */ +export const sortScorersByPoints = ( + scorers: T[] +): T[] => + [...scorers].sort( + (a, b) => b.points - a.points || a.fullName.localeCompare(b.fullName) + ); diff --git a/Club12-WebClient/src/modules/match/utils/matchFixtureCsv.ts b/Club12-WebClient/src/modules/match/utils/matchFixtureCsv.ts new file mode 100644 index 0000000..ff66613 --- /dev/null +++ b/Club12-WebClient/src/modules/match/utils/matchFixtureCsv.ts @@ -0,0 +1,34 @@ +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { + formatRoundLabel, + groupMatchesByRound, +} from '@/modules/match/utils/matchGrouping'; +import { formatDateTimeAr } from '@/modules/core/utils/formatDate'; +import { CsvRow } from '@/modules/core/utils/csv'; + +/** Column headers (HU-89) for the fixture CSV export. */ +export const FIXTURE_CSV_HEADERS = [ + 'Fecha', + 'Fecha y hora', + 'Local', + 'Visitante', + 'Resultado', + 'Estado', +]; + +const EMPTY_TEAM = '—'; + +/** Builds the CSV rows (HU-89) for a fixture, grouped and ordered by round. */ +export const buildFixtureCsvRows = (matches: IMatchResponse[]): CsvRow[] => + groupMatchesByRound(matches).flatMap(round => + round.matches.map(match => [ + formatRoundLabel(round.round), + formatDateTimeAr(match.matchDate), + match.homeTeam?.name ?? EMPTY_TEAM, + match.visitorTeam?.name ?? EMPTY_TEAM, + match.isFinished + ? `${match.homeTeam?.score ?? 0}-${match.visitorTeam?.score ?? 0}` + : EMPTY_TEAM, + match.isFinished ? 'Finalizado' : 'Programado', + ]) + ); diff --git a/Club12-WebClient/src/modules/match/utils/matchGrouping.test.ts b/Club12-WebClient/src/modules/match/utils/matchGrouping.test.ts new file mode 100644 index 0000000..6f73aea --- /dev/null +++ b/Club12-WebClient/src/modules/match/utils/matchGrouping.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { MatchType } from '@/modules/core/enum/match/matchType'; +import { IMatchResponse } from '@/modules/match/type/match'; +import { ITeamMatchResponse } from '@/modules/team/type/team'; +import { + byeTeamNamesForRound, + collectStageTeamNames, + formatRoundLabel, + groupMatchesByRound, +} from '@/modules/match/utils/matchGrouping'; + +const guid = (value: string) => value as GUID; + +const team = (name: string): ITeamMatchResponse => ({ + id: guid(`team-${name}`), + name, + logoUrl: '', + score: 0, + players: [], + scorers: [], +}); + +let sequence = 0; + +const match = (overrides: Partial): IMatchResponse => ({ + id: guid(`match-${(sequence += 1)}`), + matchDate: '2026-04-28T20:00:00Z', + round: 1, + matchType: MatchType.Regular, + slug: `match-${sequence}`, + homeTeam: team('A'), + visitorTeam: team('B'), + isFinished: false, + winningTeamId: null, + venue: null, + stageId: guid('stage-1'), + winningTeamName: null, + status: null, + ...overrides, +}); + +describe('groupMatchesByRound', () => { + it('groups matches by jornada (round), ordered ascending — not by calendar date', () => { + const matches = [ + match({ round: 2, matchDate: '2026-04-20T18:00:00Z', homeTeam: team('C'), visitorTeam: team('D') }), + match({ round: 1, matchDate: '2026-05-10T18:00:00Z', homeTeam: team('A'), visitorTeam: team('B') }), + match({ round: 1, matchDate: '2026-05-10T20:00:00Z', homeTeam: team('C'), visitorTeam: team('D') }), + ]; + + const rounds = groupMatchesByRound(matches); + + expect(rounds.map(round => round.round)).toEqual([1, 2]); + expect(rounds[0].matches).toHaveLength(2); + expect(rounds[1].matches).toHaveLength(1); + }); + + it('places matches with a null round (knockout) in a trailing group', () => { + const matches = [ + match({ round: null }), + match({ round: 1 }), + ]; + + const rounds = groupMatchesByRound(matches); + + expect(rounds.map(round => round.round)).toEqual([1, null]); + }); +}); + +describe('formatRoundLabel', () => { + it('renders "Fecha N" for a numbered jornada', () => { + expect(formatRoundLabel(1)).toBe('Fecha 1'); + expect(formatRoundLabel(18)).toBe('Fecha 18'); + }); + + it('renders a generic label for the null (knockout) group', () => { + expect(formatRoundLabel(null)).toBe('Fase final'); + }); +}); + +describe('byeTeamNamesForRound', () => { + it('reports the team with no match that round as "Libre" (odd roster)', () => { + const matches = [ + match({ round: 1, homeTeam: team('A'), visitorTeam: team('B') }), + match({ round: 2, homeTeam: team('C'), visitorTeam: team('A') }), + match({ round: 3, homeTeam: team('B'), visitorTeam: team('C') }), + ]; + const stageTeamNames = collectStageTeamNames(matches); + + // Round 1 pairs A vs B, so C sits out. + const round1 = matches.filter(item => item.round === 1); + expect(byeTeamNamesForRound(round1, stageTeamNames)).toEqual(['C']); + }); + + it('returns no byes when every team plays that round (even roster)', () => { + const matches = [ + match({ round: 1, homeTeam: team('A'), visitorTeam: team('B') }), + match({ round: 1, homeTeam: team('C'), visitorTeam: team('D') }), + ]; + const stageTeamNames = collectStageTeamNames(matches); + + expect(byeTeamNamesForRound(matches, stageTeamNames)).toEqual([]); + }); +}); diff --git a/Club12-WebClient/src/modules/match/utils/matchGrouping.ts b/Club12-WebClient/src/modules/match/utils/matchGrouping.ts new file mode 100644 index 0000000..5dd95cc --- /dev/null +++ b/Club12-WebClient/src/modules/match/utils/matchGrouping.ts @@ -0,0 +1,82 @@ +import { IMatchResponse, IRoundMatchesResponse } from '@/modules/match/type/match'; + +/** + * The label used for a team that sits out a matchday (bye). With an odd number + * of teams each round leaves exactly one team free (HU-65). + */ +export const BYE_TEAM_LABEL = 'Libre'; + +/** Sort key placing the null round (knockout matches) after the numbered ones. */ +const roundSortKey = (round: number | null): number => + round ?? Number.MAX_SAFE_INTEGER; + +/** + * Groups matches by their matchday (jornada / round) and orders the groups + * ascending — the canonical fixture rendering for HU-63/HU-65 ("Fecha 1", + * "Fecha 2", …). Matches with a null round (e.g. knockout stages) are collected + * into a single trailing group. Grouping is by the round number, never by the + * calendar date. + */ +export const groupMatchesByRound = ( + matches: IMatchResponse[] +): IRoundMatchesResponse[] => { + const byRound = new Map(); + + matches.forEach(match => { + const round = match.round ?? null; + const group = byRound.get(round) ?? []; + group.push(match); + byRound.set(round, group); + }); + + return Array.from(byRound.entries()) + .sort(([a], [b]) => roundSortKey(a) - roundSortKey(b)) + .map(([round, roundMatches]) => ({ + round, + matches: [...roundMatches].sort((a, b) => + a.matchDate.localeCompare(b.matchDate) + ), + })); +}; + +/** + * The human-readable header for a round: "Fecha 1", "Fecha 2", … for numbered + * jornadas, and a generic label for the null (knockout) group. + */ +export const formatRoundLabel = (round: number | null): string => + round == null ? 'Fase final' : `Fecha ${round}`; + +/** The name of each side of a match that is actually a team (skips byes/TBD). */ +const matchTeamNames = (match: IMatchResponse): string[] => + [match.homeTeam?.name, match.visitorTeam?.name].filter( + (name): name is string => Boolean(name) + ); + +/** + * The distinct team names appearing across every match of a stage. Used as the + * roster to derive which team is free ("Libre") on a given matchday. + */ +export const collectStageTeamNames = (matches: IMatchResponse[]): string[] => { + const names = new Set(); + matches.forEach(match => matchTeamNames(match).forEach(name => names.add(name))); + return Array.from(names); +}; + +/** + * The teams sitting out a given round (bye / "Libre", HU-65): a stage team that + * plays in some round but has no match in this one. Derivable purely from the + * fixture — no extra endpoint — as long as the full roster is known. Returns an + * empty list when the data can't support the derivation (e.g. a knockout round + * where absence just means "not drawn yet"). + */ +export const byeTeamNamesForRound = ( + roundMatches: IMatchResponse[], + stageTeamNames: string[] +): string[] => { + const playing = new Set(); + roundMatches.forEach(match => + matchTeamNames(match).forEach(name => playing.add(name)) + ); + + return stageTeamNames.filter(name => !playing.has(name)); +}; diff --git a/Club12-WebClient/src/modules/matchSeries/service/matchSeries.service.ts b/Club12-WebClient/src/modules/matchSeries/service/matchSeries.service.ts new file mode 100644 index 0000000..35020f7 --- /dev/null +++ b/Club12-WebClient/src/modules/matchSeries/service/matchSeries.service.ts @@ -0,0 +1,57 @@ +import { GUID, GenericResponsePagination } from '@/modules/core/types/types'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { + IAddGameToSeriesRequest, + IAddMatchSeriesRequest, + IMatchSeriesResponse, + ISeriesGameResponse, + MatchSeriesFiltered, +} from '@/modules/matchSeries/type/matchSeries.d'; +import { sendGet, sendPost } from '@/modules/core/utils/axiosUtils'; +import routes from '@/modules/core/constants/routes'; +import { AxiosResponse } from 'axios'; + +export const matchSeriesService = { + /** + * Creates a new best-of-N series between two teams at a stage. + * @param {IAddMatchSeriesRequest} series - The data required to create the series. + * @returns {Promise>} The response containing the created series. + */ + addMatchSeries: async ( + series: IAddMatchSeriesRequest + ): Promise> => + sendPost(routes.matchSeries, series), + + /** + * Retrieves a series by its ID. + * @param {GUID} id - The unique identifier of the series to retrieve. + * @returns {Promise>} The response containing the requested series. + */ + getMatchSeriesById: async ( + id: GUID + ): Promise> => + sendGet(`${routes.matchSeries}/${id}`), + + /** + * Retrieves a paginated list of series based on filters. + * @param {MatchSeriesFiltered} filter - The filter criteria for fetching series. + * @returns {Promise>>} + * The response containing a paginated list of series. + */ + getMatchSeriesByFilters: async ( + filter: MatchSeriesFiltered + ): Promise>> => + sendGet(routes.matchSeries, withTablePageSize(filter)), + + /** + * Schedules the next game of an existing series. + * @param {GUID} id - The unique identifier of the series to add a game to. + * @param {IAddGameToSeriesRequest} game - The game scheduling data. + * @returns {Promise>} The response containing the created game. + */ + addGameToSeries: async ( + id: GUID, + game: IAddGameToSeriesRequest + ): Promise> => + sendPost(`${routes.matchSeries}/${id}/games`, game), +}; diff --git a/Club12-WebClient/src/modules/matchSeries/type/matchSeries.d.ts b/Club12-WebClient/src/modules/matchSeries/type/matchSeries.d.ts new file mode 100644 index 0000000..b109e38 --- /dev/null +++ b/Club12-WebClient/src/modules/matchSeries/type/matchSeries.d.ts @@ -0,0 +1,53 @@ +import { Filtered, GUID } from '@/modules/core/types/types'; +import { IMinimalMatchResponse } from '@/modules/match/type/match.d'; + +/** + * A single game within a best-of-N series, including its position in the + * series alongside the usual minimal match details. + */ +export interface ISeriesGameResponse extends IMinimalMatchResponse { + gameNumber: number; +} + +/** + * A best-of-N playoff series between two teams at one bracket round, + * including its individual games. + */ +export interface IMatchSeriesResponse { + id: GUID; + stageId: GUID; + homeTeamId: GUID; + homeTeamName: string; + visitorTeamId: GUID; + visitorTeamName: string; + bestOf: number; + winningTeamId: GUID | null; + winningTeamName: string | null; + games: ISeriesGameResponse[]; +} + +/** + * The request body structure for creating a new best-of-N series between + * two teams at a stage. + */ +export interface IAddMatchSeriesRequest { + stageId: GUID; + homeTeamId: GUID; + visitorTeamId: GUID; +} + +/** + * The request body structure for scheduling the next game of an existing + * series. + */ +export interface IAddGameToSeriesRequest { + matchDate: string; + venueId?: GUID; +} + +/** + * The filter criteria for fetching series. + */ +export interface MatchSeriesFiltered extends Filtered { + stageId?: GUID; +} diff --git a/Club12-WebClient/src/modules/matchSeries/utils/groupMatchesBySeries.test.ts b/Club12-WebClient/src/modules/matchSeries/utils/groupMatchesBySeries.test.ts new file mode 100644 index 0000000..1fb2aba --- /dev/null +++ b/Club12-WebClient/src/modules/matchSeries/utils/groupMatchesBySeries.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { MatchType } from '@/modules/core/enum/match/matchType'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { IMatchSeriesResponse, ISeriesGameResponse } from '@/modules/matchSeries/type/matchSeries.d'; +import { groupMatchesBySeries } from '@/modules/matchSeries/utils/groupMatchesBySeries'; + +const guid = (value: string) => value as GUID; + +const match = (id: string): IMatchResponse => ({ + id: guid(id), + matchDate: '2026-05-01T00:00:00Z', + matchType: MatchType.Playoff, + slug: id, + homeTeam: null, + visitorTeam: null, + isFinished: false, + winningTeamId: null, + winningTeamName: null, + venue: null, + stageId: guid('stage-1'), +}); + +const game = (id: string, gameNumber: number): ISeriesGameResponse => ({ + id: guid(id), + matchDate: '2026-05-01T00:00:00Z', + homeTeamName: 'Home', + visitorTeamName: 'Visitor', + homeScore: null, + visitorScore: null, + winningTeamName: null, + isFinished: false, + matchType: MatchType.Playoff, + gameNumber, +}); + +const series = (id: string, gameIds: string[]): IMatchSeriesResponse => ({ + id: guid(id), + stageId: guid('stage-1'), + homeTeamId: guid('home'), + homeTeamName: 'Home', + visitorTeamId: guid('visitor'), + visitorTeamName: 'Visitor', + bestOf: 3, + winningTeamId: null, + winningTeamName: null, + games: gameIds.map((gameId, index) => game(gameId, index + 1)), +}); + +describe('groupMatchesBySeries', () => { + it('groups every game of a series into one entry, in the order its first game appears', () => { + const g1 = match('g1'); + const g2 = match('g2'); + const s = series('series-1', ['g1', 'g2']); + + const groups = groupMatchesBySeries([g1, g2], new Map([[s.id, s]])); + + expect(groups).toHaveLength(1); + expect(groups[0].series?.id).toBe(s.id); + expect(groups[0].matches).toEqual([g1, g2]); + }); + + it('keeps a standalone match (no series) as its own single-match group', () => { + const solo = match('solo'); + + const groups = groupMatchesBySeries([solo], new Map()); + + expect(groups).toEqual([{ series: null, matches: [solo] }]); + }); + + it('keeps two interleaved series separate, each with only its own games', () => { + const a1 = match('a1'); + const b1 = match('b1'); + const a2 = match('a2'); + const seriesA = series('series-a', ['a1', 'a2']); + const seriesB = series('series-b', ['b1']); + + const groups = groupMatchesBySeries( + [a1, b1, a2], + new Map([ + [seriesA.id, seriesA], + [seriesB.id, seriesB], + ]) + ); + + expect(groups).toHaveLength(2); + expect(groups[0].series?.id).toBe(seriesA.id); + expect(groups[0].matches).toEqual([a1, a2]); + expect(groups[1].series?.id).toBe(seriesB.id); + expect(groups[1].matches).toEqual([b1]); + }); + + it('preserves overall input order across a mix of series and standalone matches', () => { + const solo1 = match('solo1'); + const a1 = match('a1'); + const solo2 = match('solo2'); + const seriesA = series('series-a', ['a1']); + + const groups = groupMatchesBySeries([solo1, a1, solo2], new Map([[seriesA.id, seriesA]])); + + expect(groups.map(g => g.matches[0].id)).toEqual([solo1.id, a1.id, solo2.id]); + }); +}); diff --git a/Club12-WebClient/src/modules/matchSeries/utils/groupMatchesBySeries.ts b/Club12-WebClient/src/modules/matchSeries/utils/groupMatchesBySeries.ts new file mode 100644 index 0000000..6481dc0 --- /dev/null +++ b/Club12-WebClient/src/modules/matchSeries/utils/groupMatchesBySeries.ts @@ -0,0 +1,50 @@ +import { GUID } from '@/modules/core/types/types'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { IMatchSeriesResponse } from '@/modules/matchSeries/type/matchSeries.d'; + +/** One or more matches that belong together: either every game of a + * best-of-N series (`series` set), or a single standalone match + * (`series` is `null`, `matches` has exactly one entry). */ +export interface MatchGroup { + series: IMatchSeriesResponse | null; + matches: IMatchResponse[]; +} + +/** + * Groups a flat match list so a best-of-N series' individual games stay + * together instead of being interleaved with other pairs' games under the + * same stage/round (e.g. two semifinal series both playing on the same + * dates). `IMatchResponse` carries no `seriesId` of its own, so membership + * is derived from `seriesById` — every game listed on a series' `games` + * array maps back to that series. Order is preserved: each group appears + * at the position of its first match. + */ +export function groupMatchesBySeries( + matches: IMatchResponse[], + seriesById: Map +): MatchGroup[] { + const seriesIdByMatchId = new Map(); + for (const series of seriesById.values()) { + for (const game of series.games) { + seriesIdByMatchId.set(game.id, series.id); + } + } + + const groups = new Map(); + const order: string[] = []; + + for (const match of matches) { + const seriesId = seriesIdByMatchId.get(match.id); + const key = seriesId ?? `single:${match.id}`; + + let group = groups.get(key); + if (!group) { + group = { series: seriesId ? (seriesById.get(seriesId) ?? null) : null, matches: [] }; + groups.set(key, group); + order.push(key); + } + group.matches.push(match); + } + + return order.map(key => groups.get(key)!); +} diff --git a/Club12-WebClient/src/modules/medicalRecord/context/medicalRecord.context.tsx b/Club12-WebClient/src/modules/medicalRecord/context/medicalRecord.context.tsx new file mode 100644 index 0000000..7cbb060 --- /dev/null +++ b/Club12-WebClient/src/modules/medicalRecord/context/medicalRecord.context.tsx @@ -0,0 +1,134 @@ +import { AxiosResponse } from 'axios'; +import React, { + createContext, + ReactNode, + useCallback, + useMemo, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { GUID } from '@/modules/core/types/types'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { medicalRecordService } from '@/modules/medicalRecord/service/medicalRecord.service'; +import { medicalRecordKeys } from '@/modules/medicalRecord/queryKeys'; +import { + IMedicalRecordContextProps, + IMedicalRecordResponse, + IReviewMedicalRecordRequest, + IUploadMedicalRecordRequest, +} from '@/modules/medicalRecord/type/medicalRecord.d'; + +export const MedicalRecordContext = createContext< + IMedicalRecordContextProps | undefined +>(undefined); + +export const MedicalRecordProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const queryClient = useQueryClient(); + const handleUnknownError = useUnknownErrorHandler(); + + const uploadMutation = useMutation({ + mutationFn: medicalRecordService.uploadMedicalRecord, + }); + + const reviewMutation = useMutation({ + mutationFn: medicalRecordService.reviewMedicalRecord, + }); + + const uploadMedicalRecord = useCallback( + async ( + request: IUploadMedicalRecordRequest + ): Promise => { + try { + const res: AxiosResponse = + await uploadMutation.mutateAsync(request); + if (res) { + queryClient.setQueryData( + medicalRecordKeys.byRegistration( + request.playerId, + request.teamId, + request.tournamentId + ), + res + ); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [uploadMutation, queryClient, handleUnknownError] + ); + + const reviewMedicalRecord = useCallback( + async ( + request: IReviewMedicalRecordRequest + ): Promise => { + try { + const res: AxiosResponse = + await reviewMutation.mutateAsync(request); + if (res) { + queryClient.setQueryData( + medicalRecordKeys.byRegistration( + request.playerId, + request.teamId, + request.tournamentId + ), + res + ); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [reviewMutation, queryClient, handleUnknownError] + ); + + const getMedicalRecord = useCallback( + async ( + playerId: GUID, + teamId: GUID, + tournamentId: GUID + ): Promise => { + try { + const res: AxiosResponse = + await queryClient.fetchQuery({ + queryKey: medicalRecordKeys.byRegistration( + playerId, + teamId, + tournamentId + ), + queryFn: async () => + await medicalRecordService.getMedicalRecord( + playerId, + teamId, + tournamentId + ), + }); + + if (res) { + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const container: IMedicalRecordContextProps = useMemo( + () => ({ + uploadMedicalRecord, + reviewMedicalRecord, + getMedicalRecord, + }), + [uploadMedicalRecord, reviewMedicalRecord, getMedicalRecord] + ); + + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/medicalRecord/hook/medicalRecord.hook.ts b/Club12-WebClient/src/modules/medicalRecord/hook/medicalRecord.hook.ts new file mode 100644 index 0000000..7dccd27 --- /dev/null +++ b/Club12-WebClient/src/modules/medicalRecord/hook/medicalRecord.hook.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { MedicalRecordContext } from '@/modules/medicalRecord/context/medicalRecord.context'; + +export const useMedicalRecord = () => { + const context = useContext(MedicalRecordContext); + if (!context) { + throw new Error( + 'useMedicalRecord must be used within a MedicalRecordProvider' + ); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/medicalRecord/queryKeys.test.ts b/Club12-WebClient/src/modules/medicalRecord/queryKeys.test.ts new file mode 100644 index 0000000..f7e1321 --- /dev/null +++ b/Club12-WebClient/src/modules/medicalRecord/queryKeys.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { medicalRecordKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; + +describe('medicalRecordKeys', () => { + const playerId: GUID = '11111111-1111-1111-1111-111111111111'; + const teamId: GUID = '22222222-2222-2222-2222-222222222222'; + const tournamentId: GUID = '33333333-3333-3333-3333-333333333333'; + + it('byRegistration() returns the registration-scoped literal in triple order', () => { + expect( + medicalRecordKeys.byRegistration(playerId, teamId, tournamentId) + ).toEqual([ + 'medicalRecord', + 'byRegistration', + playerId, + teamId, + tournamentId, + ]); + }); +}); diff --git a/Club12-WebClient/src/modules/medicalRecord/queryKeys.ts b/Club12-WebClient/src/modules/medicalRecord/queryKeys.ts new file mode 100644 index 0000000..c142b6a --- /dev/null +++ b/Club12-WebClient/src/modules/medicalRecord/queryKeys.ts @@ -0,0 +1,11 @@ +import { GUID } from '@/modules/core/types/types'; + +/** + * React Query keys for the medical-record module. A record is identified by + * the season registration triple (player + team + tournament), so the by-key + * query is keyed by all three ids. + */ +export const medicalRecordKeys = { + byRegistration: (playerId: GUID, teamId: GUID, tournamentId: GUID) => + ['medicalRecord', 'byRegistration', playerId, teamId, tournamentId] as const, +}; diff --git a/Club12-WebClient/src/modules/medicalRecord/service/medicalRecord.service.ts b/Club12-WebClient/src/modules/medicalRecord/service/medicalRecord.service.ts new file mode 100644 index 0000000..eff6ead --- /dev/null +++ b/Club12-WebClient/src/modules/medicalRecord/service/medicalRecord.service.ts @@ -0,0 +1,93 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { GUID } from '@/modules/core/types/types'; +import { + downloadfile, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + IMedicalRecordResponse, + IReviewMedicalRecordRequest, + IUploadMedicalRecordRequest, +} from '@/modules/medicalRecord/type/medicalRecord'; + +/** + * Service for managing player medical records and the resulting per-season + * eligibility (HU-55/57/58/62). Every operation is scoped to the season + * registration triple: player + team + tournament. + */ +export const medicalRecordService = { + /** + * Uploads a player's medical-record PDF (multipart) for a team and + * tournament (HU-55). + * @param {IUploadMedicalRecordRequest} request - Player, team, tournament and PDF file. + * @returns {Promise>} The resulting record (status Pending). + */ + uploadMedicalRecord: async ( + request: IUploadMedicalRecordRequest + ): Promise> => { + const formData = new FormData(); + formData.append('PlayerId', request.playerId); + formData.append('TeamId', request.teamId); + formData.append('TournamentId', request.tournamentId); + formData.append('File', request.file); + + return await sendPost(routes.medicalRecords, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, + + /** + * Approves or rejects a player's medical record (HU-58). + * @param {IReviewMedicalRecordRequest} request - Player, team, tournament, decision and reason. + * @returns {Promise>} The resulting record. + */ + reviewMedicalRecord: async ( + request: IReviewMedicalRecordRequest + ): Promise> => + await sendPut(`${routes.medicalRecords}/review`, request), + + /** + * Retrieves the current medical-record / eligibility state of a player's + * season registration (HU-62). + * @param {GUID} playerId - The player. + * @param {GUID} teamId - The team the player is registered to. + * @param {GUID} tournamentId - The tournament (season). + * @returns {Promise>} The server response. + */ + getMedicalRecord: async ( + playerId: GUID, + teamId: GUID, + tournamentId: GUID + ): Promise> => + await sendGet(routes.medicalRecords, { playerId, teamId, tournamentId }), + + /** + * Downloads the stored ficha-médica PDF of a player's season registration + * (HU-55/HU-56). The medical-records area is private, so the file is streamed + * back through the API (as a blob) and saved locally rather than opened from a + * public URL — the record's `fileUrl` is only an internal storage reference. + * @param {GUID} playerId - The player. + * @param {GUID} teamId - The team the player is registered to. + * @param {GUID} tournamentId - The tournament (season). + * @param {string} fileName - The name to save the downloaded PDF as. + */ + downloadMedicalRecord: async ( + playerId: GUID, + teamId: GUID, + tournamentId: GUID, + fileName: string + ): Promise => { + const query = new URLSearchParams({ + playerId, + teamId, + tournamentId, + }).toString(); + + await downloadfile(`${routes.medicalRecords}/download?${query}`, fileName); + }, +}; diff --git a/Club12-WebClient/src/modules/medicalRecord/type/medicalRecord.d.ts b/Club12-WebClient/src/modules/medicalRecord/type/medicalRecord.d.ts new file mode 100644 index 0000000..937b464 --- /dev/null +++ b/Club12-WebClient/src/modules/medicalRecord/type/medicalRecord.d.ts @@ -0,0 +1,93 @@ +import { GUID } from '@/modules/core/types/types'; +import { MedicalRecordStatus } from '@/modules/core/enum/medicalRecord/medicalRecordStatus'; + +/** + * The medical-record / eligibility state of a player's season registration + * (player + team + tournament). Mirrors the backend `MedicalRecordResponse` + * (HU-55/57/58/62). + * @interface IMedicalRecordResponse + */ +export interface IMedicalRecordResponse { + playerId: GUID; + teamId: GUID; + tournamentId: GUID; + + /** The medical-record status (Pending / Approved / Rejected). */ + status: MedicalRecordStatus; + + /** True only when the record is Approved (HU-57). */ + isHabilitado: boolean; + + /** Storage reference of the uploaded file, or null if none yet. */ + fileUrl?: string | null; + + /** Original uploaded file name, or null if none yet. */ + fileName?: string | null; + + /** Reason recorded on rejection, if any. */ + reviewReason?: string | null; + + /** When the record was last approved/rejected, if ever. */ + reviewedAt?: string | null; +} + +/** + * Multipart request to upload a player's medical-record file (PDF) for a + * specific team and tournament (HU-55). + * @interface IUploadMedicalRecordRequest + */ +export interface IUploadMedicalRecordRequest { + playerId: GUID; + teamId: GUID; + tournamentId: GUID; + /** The medical-record file (PDF) to upload. */ + file: File; +} + +/** + * Owner/admin request to approve or reject a player's medical record for a + * team and tournament (HU-58). + * @interface IReviewMedicalRecordRequest + */ +export interface IReviewMedicalRecordRequest { + playerId: GUID; + teamId: GUID; + tournamentId: GUID; + /** True to approve (player becomes habilitado); false to reject. */ + approve: boolean; + /** Optional reason, typically recorded when rejecting. */ + reason?: string; +} + +/** + * Context properties and methods for managing player medical records and the + * resulting per-season eligibility (HU-55/57/58/62). + * @interface IMedicalRecordContextProps + */ +export interface IMedicalRecordContextProps { + /** + * Uploads a player's medical-record PDF for a team and tournament (HU-55). + * The record starts Pending until reviewed. + */ + uploadMedicalRecord( + request: IUploadMedicalRecordRequest + ): Promise; + + /** + * Approves or rejects a player's medical record (HU-58). Approving makes the + * player habilitado (HU-57); rejecting leaves them not-habilitado. + */ + reviewMedicalRecord( + request: IReviewMedicalRecordRequest + ): Promise; + + /** + * Fetches the current medical-record / eligibility state of a player's + * season registration (HU-62), or void when none exists. + */ + getMedicalRecord( + playerId: GUID, + teamId: GUID, + tournamentId: GUID + ): Promise; +} diff --git a/Club12-WebClient/src/modules/medicalRecord/utils/medicalRecordDisplay.test.ts b/Club12-WebClient/src/modules/medicalRecord/utils/medicalRecordDisplay.test.ts new file mode 100644 index 0000000..340423e --- /dev/null +++ b/Club12-WebClient/src/modules/medicalRecord/utils/medicalRecordDisplay.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { MedicalRecordStatus } from '@/modules/core/enum/medicalRecord/medicalRecordStatus'; +import { + getHabilitacionColor, + getHabilitacionLabel, + getMedicalRecordDetail, + isStoredMedicalRecordFile, + LEGACY_MEDICAL_RECORD_PREFIX, + resolveIsHabilitado, +} from './medicalRecordDisplay'; + +describe('medicalRecordDisplay', () => { + it('resolves habilitado from the explicit flag first', () => { + expect(resolveIsHabilitado(true, MedicalRecordStatus.Rejected)).toBe(true); + expect(resolveIsHabilitado(false, MedicalRecordStatus.Approved)).toBe(false); + }); + + it('falls back to Approved status when the flag is missing', () => { + expect(resolveIsHabilitado(undefined, MedicalRecordStatus.Approved)).toBe( + true + ); + expect(resolveIsHabilitado(undefined, MedicalRecordStatus.Pending)).toBe( + false + ); + expect(resolveIsHabilitado(null, null)).toBe(false); + }); + + it('labels habilitado vs not habilitado', () => { + expect(getHabilitacionLabel(true)).toBe('Habilitado'); + expect(getHabilitacionLabel(false, MedicalRecordStatus.Pending)).toBe( + 'No habilitado' + ); + }); + + it('colors success / error / warning by status', () => { + expect(getHabilitacionColor(true, MedicalRecordStatus.Approved)).toBe( + 'success' + ); + expect(getHabilitacionColor(false, MedicalRecordStatus.Rejected)).toBe( + 'error' + ); + expect(getHabilitacionColor(false, MedicalRecordStatus.Pending)).toBe( + 'warning' + ); + expect(getHabilitacionColor(false, null)).toBe('warning'); + }); + + it('describes the medical-record detail', () => { + expect(getMedicalRecordDetail(null)).toBe('Sin ficha médica cargada'); + expect(getMedicalRecordDetail(MedicalRecordStatus.Approved)).toBe( + 'Ficha médica: Aprobada' + ); + expect(getMedicalRecordDetail(MedicalRecordStatus.Rejected)).toBe( + 'Ficha médica: Rechazada' + ); + }); + + describe('isStoredMedicalRecordFile', () => { + it('is false when there is no fileUrl at all', () => { + expect(isStoredMedicalRecordFile(undefined)).toBe(false); + expect(isStoredMedicalRecordFile(null)).toBe(false); + expect(isStoredMedicalRecordFile('')).toBe(false); + }); + + it('is false for a legacy medical-records/ prefixed reference', () => { + expect( + isStoredMedicalRecordFile(`${LEGACY_MEDICAL_RECORD_PREFIX}some/object/path.pdf`) + ).toBe(false); + }); + + it('is true for a new-scheme {teamId}/{playerId}/{guid} reference', () => { + expect( + isStoredMedicalRecordFile( + '22222222-2222-2222-2222-222222222222/11111111-1111-1111-1111-111111111111/abc.pdf' + ) + ).toBe(true); + }); + }); +}); diff --git a/Club12-WebClient/src/modules/medicalRecord/utils/medicalRecordDisplay.ts b/Club12-WebClient/src/modules/medicalRecord/utils/medicalRecordDisplay.ts new file mode 100644 index 0000000..13102ae --- /dev/null +++ b/Club12-WebClient/src/modules/medicalRecord/utils/medicalRecordDisplay.ts @@ -0,0 +1,82 @@ +import { MedicalRecordStatus } from '@/modules/core/enum/medicalRecord/medicalRecordStatus'; + +/** MUI Chip color used by the habilitación / medical-record badges. */ +export type HabilitacionColor = 'success' | 'warning' | 'error' | 'default'; + +export const HABILITADO_LABEL = 'Habilitado'; +export const NO_HABILITADO_LABEL = 'No habilitado'; + +/** Spanish label for each raw medical-record status. */ +export const MEDICAL_RECORD_STATUS_LABELS: Record = + { + [MedicalRecordStatus.Pending]: 'Pendiente', + [MedicalRecordStatus.Approved]: 'Aprobada', + [MedicalRecordStatus.Rejected]: 'Rechazada', + }; + +/** + * Resolves whether a player is habilitado from the roster/medical-record + * signals. Prefers the explicit `isHabilitado` flag and falls back to the + * status being Approved when the flag was not populated (HU-57). + */ +export const resolveIsHabilitado = ( + isHabilitado?: boolean | null, + status?: MedicalRecordStatus | null +): boolean => isHabilitado ?? status === MedicalRecordStatus.Approved; + +/** The badge label: "Habilitado" / "No habilitado" (HU-57/HU-62). */ +export const getHabilitacionLabel = ( + isHabilitado?: boolean | null, + status?: MedicalRecordStatus | null +): string => + resolveIsHabilitado(isHabilitado, status) + ? HABILITADO_LABEL + : NO_HABILITADO_LABEL; + +/** + * The badge color: success when habilitado, error when the record was + * explicitly rejected, warning otherwise (pending / no record). Makes a + * not-habilitado player visually clear (HU-62/HU-61). + */ +export const getHabilitacionColor = ( + isHabilitado?: boolean | null, + status?: MedicalRecordStatus | null +): HabilitacionColor => { + if (resolveIsHabilitado(isHabilitado, status)) { + return 'success'; + } + + return status === MedicalRecordStatus.Rejected ? 'error' : 'warning'; +}; + +/** + * A short human explanation of the current medical-record situation, used as + * the badge tooltip / secondary text. + */ +export const getMedicalRecordDetail = ( + status?: MedicalRecordStatus | null +): string => { + if (!status) { + return 'Sin ficha médica cargada'; + } + + return `Ficha médica: ${MEDICAL_RECORD_STATUS_LABELS[status]}`; +}; + +/** + * Mirrors the backend Domain discriminator + * (`PlayerTeamRegistration.LegacyReferencePrefix`): refs written before the + * private-bucket relocation lived under this prefix inside `public-images` + * and no longer resolve. + */ +export const LEGACY_MEDICAL_RECORD_PREFIX = 'medical-records/'; + +/** + * Whether `fileUrl` is a real, resolvable stored file reference — non-empty + * and not a legacy `LEGACY_MEDICAL_RECORD_PREFIX` ref. Drives the "Aprobar" + * gate in `PlayerMedicalRecordDialog`: approving with no real stored file is + * rejected server-side (409), so the UI disables the action up front. + */ +export const isStoredMedicalRecordFile = ( + fileUrl?: string | null +): boolean => Boolean(fileUrl) && !fileUrl!.startsWith(LEGACY_MEDICAL_RECORD_PREFIX); diff --git a/Club12-WebClient/src/modules/player/context/player.context.tsx b/Club12-WebClient/src/modules/player/context/player.context.tsx new file mode 100644 index 0000000..269bc62 --- /dev/null +++ b/Club12-WebClient/src/modules/player/context/player.context.tsx @@ -0,0 +1,246 @@ +import { AxiosResponse } from 'axios'; +import React, { + createContext, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { playerService } from '@/modules/player/service/player.service'; +import { + IAddPlayerRequest, + IPlayerContextProps, + PlayerFiltered, + IPlayerResponse, + IPutPlayerRequest, + IRegisterPlayerToTeamRequest, + PlayerRegistrationResult, +} from '@/modules/player/type/player.d'; +import { upsertListById } from '@/modules/core/utils/synchronizeStates'; +import { playerKeys } from '@/modules/player/queryKeys'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; +import { mapRosterConflictMessage } from '@/modules/player/utils/rosterConflict'; +import { + extractProblemDetail, + MutationResult, +} from '@/modules/core/utils/problemDetails'; + +export const PlayerContext = createContext( + undefined +); + +export const PlayerProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [player, setPlayer] = useState(null); + const [players, setPlayers] = useState(null); + + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addPlayerMutation = useMutation({ + mutationFn: playerService.addPlayer, + }); + + const putPlayerMutation = useMutation({ + mutationFn: ({ id, player }: { id: GUID; player: IPutPlayerRequest }) => + playerService.putPlayerById(id, player), + }); + + const deletePlayerMutation = useMutation({ + mutationFn: playerService.deletePlayerById, + }); + + const registerPlayerMutation = useMutation({ + mutationFn: ({ + playerId, + request, + }: { + playerId: GUID; + request: IRegisterPlayerToTeamRequest; + }) => playerService.registerPlayerToTeam(playerId, request), + }); + + useEffect(() => { + if (!player) return; + + setPlayers(prev => upsertListById(prev, player)); + }, [player]); + + const addPlayer = useCallback( + async (player: IAddPlayerRequest): Promise => { + try { + const res: AxiosResponse = + await addPlayerMutation.mutateAsync(player); + if (res) { + setPlayer(res.data); + queryClient.setQueryData(playerKeys.byId(res.data.id), res); + await queryClient.invalidateQueries({ queryKey: playerKeys.list() }); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addPlayerMutation, queryClient, handleUnknownError] + ); + + const getPlayerById = useCallback( + async ( + idOrSlug: string, + isAdministrative: boolean = false + ): Promise => { + try { + const res: AxiosResponse = + await queryClient.fetchQuery({ + queryKey: playerKeys.byId(idOrSlug, isAdministrative), + queryFn: async () => + await playerService.getPlayerById(idOrSlug, isAdministrative), + }); + + if (res) { + setPlayer(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + const getPlayersByFilter = useCallback( + async ( + filter: PlayerFiltered + ): Promise | void> => { + try { + const res = await queryClient.fetchQuery({ + queryKey: playerKeys.list(filter), + queryFn: async () => await playerService.getPlayersByFilter(filter), + }); + + if (res?.data?.items) { + setPlayers(res.data.items); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + const putPlayerById = useCallback( + async ( + id: GUID, + player: IPutPlayerRequest + ): Promise => { + try { + const res: AxiosResponse = + await putPlayerMutation.mutateAsync({ id, player }); + if (res) { + if (res.status === HttpStatus.NoContent) { + setPlayer(prev => + prev && prev.id === id ? { ...prev, ...player } : prev + ); + } else if (res.data) { + setPlayer(res.data); + queryClient.setQueryData(playerKeys.byId(id), res); + } + await queryClient.invalidateQueries({ queryKey: playerKeys.list() }); + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putPlayerMutation, queryClient, handleUnknownError] + ); + + /** + * Unlike the read mutations, a delete can be blocked by a backend integrity + * rule (a player with statistics/scorers/sanctions returns a 409 with a + * Spanish message). The failure is returned as a discriminated result so the + * caller can surface that exact reason inline instead of swallowing it. + */ + const deletePlayerById = useCallback( + async (id: GUID): Promise => { + try { + await deletePlayerMutation.mutateAsync(id); + setPlayer(null); + setPlayers(prev => prev?.filter(e => e.id !== id) ?? null); + queryClient.removeQueries({ queryKey: playerKeys.byId(id) }); + await queryClient.invalidateQueries({ queryKey: playerKeys.list() }); + return { success: true }; + } catch (error: unknown) { + return { + success: false, + errorMessage: + extractProblemDetail(error) ?? + 'No se pudo eliminar el jugador. Intentá nuevamente.', + }; + } + }, + [deletePlayerMutation, queryClient] + ); + + /** + * Unlike the other mutations, this one does not funnel a failure through the + * global error handler: the roster invariants (HU-54) come back as a 409 the + * caller must surface inline with the exact reason, so we translate the + * conflict into a discriminated result instead of swallowing it. On success + * the player/team lists are invalidated so the refreshed dorsal is picked up. + */ + const registerPlayerToTeam = useCallback( + async ( + playerId: GUID, + request: IRegisterPlayerToTeamRequest + ): Promise => { + try { + const res = await registerPlayerMutation.mutateAsync({ + playerId, + request, + }); + await queryClient.invalidateQueries({ queryKey: playerKeys.list() }); + return { success: true, data: res.data }; + } catch (error: unknown) { + return { + success: false, + errorMessage: mapRosterConflictMessage(error), + }; + } + }, + [registerPlayerMutation, queryClient] + ); + + const container: IPlayerContextProps = useMemo( + () => ({ + player, + players, + addPlayer, + getPlayerById, + getPlayersByFilter, + putPlayerById, + deletePlayerById, + registerPlayerToTeam, + }), + [ + player, + players, + addPlayer, + getPlayerById, + getPlayersByFilter, + putPlayerById, + deletePlayerById, + registerPlayerToTeam, + ] + ); + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/player/hook/player.hook.ts b/Club12-WebClient/src/modules/player/hook/player.hook.ts new file mode 100644 index 0000000..8df7168 --- /dev/null +++ b/Club12-WebClient/src/modules/player/hook/player.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { PlayerContext } from '@/modules/player/context/player.context'; + +export const usePlayer = () => { + const context = useContext(PlayerContext); + if (!context) { + throw new Error('usePlayer must be used within a PlayerProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/player/queryKeys.test.ts b/Club12-WebClient/src/modules/player/queryKeys.test.ts new file mode 100644 index 0000000..2cdf58d --- /dev/null +++ b/Club12-WebClient/src/modules/player/queryKeys.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { playerKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; +import { PlayerFiltered } from '@/modules/player/type/player.d'; + +describe('playerKeys', () => { + const id: GUID = '99999999-9999-9999-9999-999999999999'; + + it('list() returns the bare list literal with no trailing undefined', () => { + expect(playerKeys.list()).toEqual(['player', 'list']); + }); + + it('list(filter) returns the filtered list literal', () => { + const filter: PlayerFiltered = { pageNumber: 1 }; + expect(playerKeys.list(filter)).toEqual(['player', 'list', filter]); + }); + + it('byId(id) returns the 3-element by-id literal when isAdministrative is omitted', () => { + expect(playerKeys.byId(id)).toEqual(['player', 'byId', id]); + }); + + it('byId(id, isAdministrative) returns the 4-element by-id literal', () => { + expect(playerKeys.byId(id, true)).toEqual(['player', 'byId', id, true]); + expect(playerKeys.byId(id, false)).toEqual(['player', 'byId', id, false]); + }); +}); diff --git a/Club12-WebClient/src/modules/player/queryKeys.ts b/Club12-WebClient/src/modules/player/queryKeys.ts new file mode 100644 index 0000000..eb70837 --- /dev/null +++ b/Club12-WebClient/src/modules/player/queryKeys.ts @@ -0,0 +1,12 @@ +import { PlayerFiltered } from '@/modules/player/type/player.d'; + +export const playerKeys = { + list: (filter?: PlayerFiltered) => + filter === undefined + ? (['player', 'list'] as const) + : (['player', 'list', filter] as const), + byId: (idOrSlug: string, isAdministrative?: boolean) => + isAdministrative === undefined + ? (['player', 'byId', idOrSlug] as const) + : (['player', 'byId', idOrSlug, isAdministrative] as const), +}; diff --git a/Club12-WebClient/src/modules/player/service/player.service.ts b/Club12-WebClient/src/modules/player/service/player.service.ts new file mode 100644 index 0000000..8406688 --- /dev/null +++ b/Club12-WebClient/src/modules/player/service/player.service.ts @@ -0,0 +1,93 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + IAddPlayerRequest, + PlayerFiltered, + IPlayerResponse, + IPlayerRegistrationResponse, + IPutPlayerRequest, + IRegisterPlayerToTeamRequest, +} from '@/modules/player/type/player'; + +/** + * Service for managing player-related operations. + */ +export const playerService = { + /** + * Adds a new player. + * @param {IAddPlayerRequest} player - The player details to add. + * @returns {Promise>} The server response. + */ + addPlayer: async ( + player: IAddPlayerRequest + ): Promise> => + sendPost(routes.players, player), + + /** + * Retrieves a player by their ID or their public slug. + * @param {string} idOrSlug - The ID or slug of the player to retrieve. + * @param {boolean} isAdministrative - Whether to fetch the player using the administrative route. + * @returns {Promise>} The server response. + */ + getPlayerById: async ( + idOrSlug: string, + isAdministrative: boolean + ): Promise> => { + const resource: string = isAdministrative + ? `${routes.players}/admin` + : routes.players; + return sendGet(`${resource}/${idOrSlug}`); + }, + + /** + * Retrieves a list of players based on a filter. + * @param {PlayerFiltered} filter - The filter criteria for retrieving players. + * @returns {Promise>} The server response. + */ + getPlayersByFilter: async ( + filter: PlayerFiltered + ): Promise>> => + sendGet(routes.players, withTablePageSize(filter)), + + /** + * Updates the details of an existing player. + * @param {string} id - The ID of the player to update. + * @param {IPutPlayerRequest} player - The updated player details. + * @returns {Promise>} The server response. + */ + putPlayerById: async ( + id: GUID, + player: IPutPlayerRequest + ): Promise> => + sendPut(`${routes.players}/${id}`, player), + + /** + * Deletes a player by their ID. + * @param {string} id - The ID of the player to delete. + * @returns {Promise>} The server response. + */ + deletePlayerById: async (id: GUID): Promise> => + sendDelete(`${routes.players}/${id}`), + + /** + * Registers a player onto a team's roster for a season, optionally assigning + * a dorsal (HU-54). The backend enforces the roster invariants and answers + * with a 409 Conflict when one is violated. + * @param {GUID} playerId - The player to register. + * @param {IRegisterPlayerToTeamRequest} request - Team, tournament and dorsal. + * @returns {Promise>} The outcome. + */ + registerPlayerToTeam: async ( + playerId: GUID, + request: IRegisterPlayerToTeamRequest + ): Promise> => + sendPost(`${routes.players}/${playerId}/registration`, request), +}; diff --git a/Club12-WebClient/src/modules/player/type/player.d.ts b/Club12-WebClient/src/modules/player/type/player.d.ts new file mode 100644 index 0000000..44f47f9 --- /dev/null +++ b/Club12-WebClient/src/modules/player/type/player.d.ts @@ -0,0 +1,344 @@ +import { + Filtered, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { MedicalRecordStatus } from '@/modules/core/enum/medicalRecord/medicalRecordStatus'; +import { MutationResult } from '@/modules/core/utils/problemDetails'; + +/** + * Context properties and methods for managing players in a sports system. + * These methods interact with the backend for creating, updating, fetching, and deleting players. + * @interface IPlayerContextProps + */ +export interface IPlayerContextProps { + player: IPlayerResponse | null; + players: IPlayerResponse[] | null; + /** + * Adds a new player to the system. + * @param player The details of the player to add. + * @returns A promise that resolves with the response containing the newly added player. + */ + addPlayer(player: IAddPlayerRequest): Promise; + + /** + * Fetches a player by its ID or its public slug. + * @param idOrSlug The ID or slug of the player to fetch. + * @param isAdministrative Whether to use the administrative route to fetch the player. Defaults to false. + * @returns A promise that resolves with the player details. + */ + getPlayerById( + idOrSlug: string, + isAdministrative: boolean = false + ): Promise; + + /** + * Fetches players based on filters and pagination. + * @param filter The filter criteria to apply when fetching players. + * @returns A promise that resolves with a paginated response containing filtered players. + */ + getPlayersByFilter( + filter: PlayerFiltered + ): Promise | void>; + + /** + * Updates a player's information. + * @param id The ID of the player to update. + * @param player The updated player details. + * @returns A promise that resolves when the player is successfully updated. + */ + putPlayerById( + id: GUID, + player: IPutPlayerRequest + ): Promise; + + /** + * Deletes a player by its ID. Resolves with a discriminated result so callers + * can surface a backend integrity block (a player with statistics/scorers/ + * sanctions is rejected with a 409 and a Spanish message). + * @param id The ID of the player to delete. + */ + deletePlayerById(id: GUID): Promise; + + /** + * Registers a player onto a team's roster for a tournament season, + * optionally assigning a dorsal (HU-54). Resolves with a discriminated + * result so callers can surface the specific roster-invariant conflict + * (duplicate dorsal / roster full / already in another team) returned as a + * 409 by the backend. + * @param playerId The player to register. + * @param request The team, tournament and optional dorsal. + */ + registerPlayerToTeam( + playerId: GUID, + request: IRegisterPlayerToTeamRequest + ): Promise; +} + +/** + * The request body for registering a player onto a team roster (HU-54). + * @interface IRegisterPlayerToTeamRequest + */ +export interface IRegisterPlayerToTeamRequest { + teamId: GUID; + tournamentId: GUID; + /** The dorsal to assign for this team/season, or null to leave it unset. */ + jerseyNumber?: number | null; +} + +/** + * The successful outcome of a roster registration (HU-54). + * @interface IPlayerRegistrationResponse + */ +export interface IPlayerRegistrationResponse { + playerId: GUID; + teamId: GUID; + tournamentId: GUID; + jerseyNumber?: number | null; +} + +/** + * Discriminated result of {@link IPlayerContextProps.registerPlayerToTeam}: + * either the registration succeeded, or it failed with a user-facing message + * mapped from the backend roster conflict (HU-54). + */ +export type PlayerRegistrationResult = + | { success: true; data: IPlayerRegistrationResponse } + | { success: false; errorMessage: string }; + +/** + * The filter criteria for fetching players, which includes the player's name and document number. + * @interface PlayerFiltered + */ +export interface PlayerFiltered extends Filtered { + /** + * The name(s) of the player to filter by. + * @type {string} + */ + names?: string; + + /** + * The last name of the player. + * @type {string} + */ + lastName?: string; + + /** + * The document number of the player (e.g., ID, passport). + * @type {string} + */ + documentNumber?: string; + + /** + * The ID of the team the player belongs to. + * @type {GUID} + */ + teamId?: GUID; + + birthDate?: Date; + + phoneNumber?: string; + + socialSecurity?: string; + + isFederated?: boolean; + + club?: string; + + category?: string; +} + +/** + * The request body structure for adding a new player. + * @interface IAddPlayerRequest + */ +export interface IAddPlayerRequest { + /** + * The first name of the player. + * @type {string} + */ + firstName: string; + + /** + * The second name of the player (if applicable). + * @type {string} + */ + secondName?: string; + + /** + * The last name of the player. + * @type {string} + */ + lastName: string; + + /** + * The document number of the player (e.g., ID, passport). + * @type {string} + */ + documentNumber: string; + + /** + * The ID of the team the player belongs to. + * @type {GUID} + */ + teamId: GUID; + + birthDate: Date; + + phoneNumber: string; + + socialSecurity: string; +} + +/** + * The response structure for a player, including the player's personal information and team ID. + * @interface PlayerResponse + * @extends IAddPlayerRequest + */ +export interface IPlayerResponse extends IAddPlayerRequest { + /** + * The unique identifier of the player. + * @type {string} + */ + id: GUID; + + /** + * The unique, URL-friendly identifier used in public player links. + * @type {string} + */ + slug: string; + + fullName: string; + + isFederated: boolean; + + club: string; + + category: string; + + /** + * The player's medical-record status for the season roster this response + * belongs to (HU-57), when the backend populated it for a specific season. + * @type {MedicalRecordStatus} + */ + medicalRecordStatus?: MedicalRecordStatus | null; + + /** + * Whether the player is habilitado (medical record Approved) for this + * season roster (HU-57). + * @type {boolean} + */ + isHabilitado?: boolean; + + /** + * The player's dorsal (jersey number) for this season roster (HU-54). Null + * or undefined when unassigned or when the roster was not loaded for a + * specific season. + * @type {number} + */ + jerseyNumber?: number | null; +} + +export interface IPublicPlayerResponse { + /** + * The unique identifier of the player. + * @type {GUID} + */ + id: GUID; + + /** + * The first name of the player. + * @type {string} + */ + firstName: string; + + /** + * The second name of the player. + * @type {string} + */ + secondName: string; + + /** + * The last name of the player. + * @type {string} + */ + lastName: string; + + /** + * The full name of the player. + * @type {string} + */ + fullName: string; + + /** + * The unique identifier of the team to which the player belongs. + * @type {GUID} + */ + teamId: GUID; + + /** + * The player's medical-record status for the season roster this response + * belongs to (HU-57). Null/undefined when the roster was not loaded for a + * specific season. + * @type {MedicalRecordStatus} + */ + medicalRecordStatus?: MedicalRecordStatus | null; + + /** + * Whether the player is habilitado (medical record Approved) for this + * season roster (HU-57), so the UI can flag not-habilitado players (HU-62). + * @type {boolean} + */ + isHabilitado?: boolean; + + /** + * The player's dorsal (jersey number) for this season roster (HU-54). Null + * or undefined when unassigned. + * @type {number} + */ + jerseyNumber?: number | null; +} + +/** + * The request body structure for updating a player's information. + * @interface IPutPlayerRequest + */ +export interface IPutPlayerRequest { + /** + * The first name of the player. + * @type {string} + */ + firstName: string; + + /** + * The second name of the player (if applicable). + * @type {string} + */ + secondName?: string; + + /** + * The last name of the player. + * @type {string} + */ + lastName?: string; + + /** + * The document number of the player. + * @type {string} + */ + documentNumber?: string; + + birthDate?: Date; + + phoneNumber?: string; + + socialSecurity?: string; + + teamId?: GUID; + + isFederated?: boolean; + + club?: string; + + category?: string; +} diff --git a/Club12-WebClient/src/modules/player/utils/rosterConflict.test.ts b/Club12-WebClient/src/modules/player/utils/rosterConflict.test.ts new file mode 100644 index 0000000..ed57c29 --- /dev/null +++ b/Club12-WebClient/src/modules/player/utils/rosterConflict.test.ts @@ -0,0 +1,62 @@ +import { AxiosError, AxiosHeaders } from 'axios'; +import { describe, expect, it } from 'vitest'; +import { + ROSTER_CONFLICT_MESSAGES, + mapRosterConflictMessage, +} from '@/modules/player/utils/rosterConflict'; + +const conflict = (detail: string): AxiosError => { + const error = new AxiosError('Conflict'); + error.response = { + status: 409, + statusText: 'Conflict', + data: { detail }, + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + return error; +}; + +describe('mapRosterConflictMessage (HU-54)', () => { + it('maps a duplicate dorsal 409 to a clear message', () => { + const message = mapRosterConflictMessage( + conflict("Jersey number 10 is already used by another player in team 'x'.") + ); + + expect(message).toBe(ROSTER_CONFLICT_MESSAGES.duplicateDorsal); + }); + + it('maps a roster-full 409 to a clear message', () => { + const message = mapRosterConflictMessage( + conflict("Team 'x' already has the maximum of 12 players for this tournament.") + ); + + expect(message).toBe(ROSTER_CONFLICT_MESSAGES.rosterFull); + }); + + it('maps a player-already-in-another-team 409 to a clear message', () => { + const message = mapRosterConflictMessage( + conflict( + "Player 'p' is already registered to another team in tournament 't'. " + + 'A player cannot be registered to two teams in the same tournament.' + ) + ); + + expect(message).toBe(ROSTER_CONFLICT_MESSAGES.alreadyInAnotherTeam); + }); + + it('falls back to a generic message for a non-conflict error', () => { + const error = new AxiosError('Boom'); + error.response = { + status: 500, + statusText: 'Server Error', + data: {}, + headers: {}, + config: { headers: new AxiosHeaders() }, + }; + + expect(mapRosterConflictMessage(error)).toBe( + ROSTER_CONFLICT_MESSAGES.generic + ); + }); +}); diff --git a/Club12-WebClient/src/modules/player/utils/rosterConflict.ts b/Club12-WebClient/src/modules/player/utils/rosterConflict.ts new file mode 100644 index 0000000..f7d8081 --- /dev/null +++ b/Club12-WebClient/src/modules/player/utils/rosterConflict.ts @@ -0,0 +1,62 @@ +import { AxiosError } from 'axios'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +/** + * User-facing (Spanish) messages for the three roster invariants the backend + * enforces on registration, returned as a 409 Conflict (HU-54). + */ +export const ROSTER_CONFLICT_MESSAGES = { + duplicateDorsal: + 'Ese dorsal ya está asignado a otro jugador del equipo en esta temporada.', + rosterFull: 'El plantel ya alcanzó el máximo de jugadores permitido.', + alreadyInAnotherTeam: + 'El jugador ya está inscripto en otro equipo de este torneo.', + generic: 'No se pudo asignar el dorsal. Intentá nuevamente.', +} as const; + +/** + * Reads the ProblemDetails `detail` string from an Axios error response, when + * present. The backend returns the raw business message there for 409s. + */ +const extractDetail = (error: unknown): string | undefined => { + if (!(error instanceof AxiosError)) { + return undefined; + } + + const data = error.response?.data as { detail?: unknown } | undefined; + return typeof data?.detail === 'string' ? data.detail : undefined; +}; + +/** + * Maps a failed roster-registration error into a clear Spanish message, + * distinguishing the three 409 conflicts by matching the backend detail text + * (duplicate dorsal / roster full / player already in another team). Any other + * error falls back to a generic message. + * + * @param error The error thrown by the registration request. + * @returns The user-facing message to surface. + */ +export const mapRosterConflictMessage = (error: unknown): string => { + const status = + error instanceof AxiosError ? error.response?.status : undefined; + + if (status !== HttpStatus.Conflict) { + return ROSTER_CONFLICT_MESSAGES.generic; + } + + const detail = (extractDetail(error) ?? '').toLowerCase(); + + if (detail.includes('jersey number')) { + return ROSTER_CONFLICT_MESSAGES.duplicateDorsal; + } + + if (detail.includes('maximum of')) { + return ROSTER_CONFLICT_MESSAGES.rosterFull; + } + + if (detail.includes('another team') || detail.includes('two teams')) { + return ROSTER_CONFLICT_MESSAGES.alreadyInAnotherTeam; + } + + return ROSTER_CONFLICT_MESSAGES.generic; +}; diff --git a/Club12-WebClient/src/modules/playerSanction/context/playerSanction.context.tsx b/Club12-WebClient/src/modules/playerSanction/context/playerSanction.context.tsx new file mode 100644 index 0000000..52437ff --- /dev/null +++ b/Club12-WebClient/src/modules/playerSanction/context/playerSanction.context.tsx @@ -0,0 +1,267 @@ +import { AxiosResponse } from 'axios'; +import React, { + createContext, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + FetchOptions, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { playerSanctionService } from '@/modules/playerSanction/service/playerSanction.service'; +import { + IAddPlayerSanction, + IAppealPlayerSanction, + IPlayerSanctionContextProps, + IPlayerSanctionFiltered, + IPlayerSanctionResponse, + IPutPlayerSanction, + IResolveAppeal, +} from '@/modules/playerSanction/type/playerSanction.d'; +import { upsertListById } from '@/modules/core/utils/synchronizeStates'; +import { playerSanctionKeys } from '@/modules/playerSanction/queryKeys'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +export const PlayerSanctionContext = createContext< + IPlayerSanctionContextProps | undefined +>(undefined); + +export const PlayerSanctionProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [playerSanction, setPlayerSanction] = + useState(null); + const [playerSanctions, setPlayerSanctions] = useState< + IPlayerSanctionResponse[] | null + >(null); + + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addPlayerSanctionMutation = useMutation({ + mutationFn: playerSanctionService.addPlayerSanction, + }); + + const putPlayerSanctionMutation = useMutation({ + mutationFn: ({ + id, + sanction, + }: { + id: GUID; + sanction: IPutPlayerSanction; + }) => playerSanctionService.putPlayerSanctionById(id, sanction), + }); + + const deletePlayerSanctionMutation = useMutation({ + mutationFn: playerSanctionService.deletePlayerSanction, + }); + + useEffect(() => { + if (!playerSanction) return; + setPlayerSanctions(prev => upsertListById(prev, playerSanction)); + }, [playerSanction]); + + const addPlayerSanction = useCallback( + async ( + sanction: IAddPlayerSanction + ): Promise => { + try { + const res: AxiosResponse = + await addPlayerSanctionMutation.mutateAsync(sanction); + if (res?.data) { + setPlayerSanction(res.data); + queryClient.setQueryData( + playerSanctionKeys.byId(res.data.id), + res + ); + await queryClient.invalidateQueries({ + queryKey: playerSanctionKeys.list(), + }); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addPlayerSanctionMutation, queryClient, handleUnknownError] + ); + + const getPlayerSanctionById = useCallback( + async (idOrSlug: string): Promise => { + try { + const res: AxiosResponse = + await queryClient.fetchQuery({ + queryKey: playerSanctionKeys.byId(idOrSlug), + queryFn: async () => + await playerSanctionService.getPlayerSanctionById(idOrSlug), + }); + + if (res) { + setPlayerSanction(res.data); + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const getPlayerSanctionByFilter = useCallback( + async ( + filter: IPlayerSanctionFiltered, + options?: FetchOptions + ): Promise | void> => { + try { + const res = await queryClient.fetchQuery({ + queryKey: playerSanctionKeys.list(filter), + queryFn: async () => + await playerSanctionService.getPlayerSanctionByFilter(filter), + }); + + if (res?.data?.items) { + setPlayerSanctions(res.data.items); + return res.data; + } + } catch (error: unknown) { + if (!options?.silent) handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const putPlayerSanctionById = useCallback( + async ( + id: GUID, + sanction: IPutPlayerSanction + ): Promise => { + try { + const res: AxiosResponse = + await putPlayerSanctionMutation.mutateAsync({ id, sanction }); + if (res) { + if (res.status === HttpStatus.NoContent) { + setPlayerSanction(prev => + prev && prev.id === id ? { ...prev, ...sanction } : prev + ); + } else if (res.data) { + setPlayerSanction(res.data); + queryClient.setQueryData(playerSanctionKeys.byId(id), res); + } + await queryClient.invalidateQueries({ + queryKey: playerSanctionKeys.list(), + }); + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putPlayerSanctionMutation, queryClient, handleUnknownError] + ); + + const deletePlayerSanction = useCallback( + async (id: GUID): Promise => { + try { + await deletePlayerSanctionMutation.mutateAsync(id); + setPlayerSanction(null); + setPlayerSanctions(prev => prev?.filter(e => e.id !== id) ?? null); + queryClient.removeQueries({ + queryKey: playerSanctionKeys.byId(id), + }); + await queryClient.invalidateQueries({ + queryKey: playerSanctionKeys.list(), + }); + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [deletePlayerSanctionMutation, queryClient, handleUnknownError] + ); + + const appealPlayerSanction = useCallback( + async ( + id: GUID, + appeal: IAppealPlayerSanction + ): Promise => { + try { + const res: AxiosResponse = + await playerSanctionService.appealPlayerSanction(id, appeal); + if (res?.data) { + setPlayerSanction(res.data); + queryClient.setQueryData(playerSanctionKeys.byId(id), res); + await queryClient.invalidateQueries({ + queryKey: playerSanctionKeys.list(), + }); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const resolvePlayerSanctionAppeal = useCallback( + async ( + id: GUID, + resolution: IResolveAppeal + ): Promise => { + try { + const res: AxiosResponse = + await playerSanctionService.resolvePlayerSanctionAppeal( + id, + resolution + ); + if (res?.data) { + setPlayerSanction(res.data); + queryClient.setQueryData(playerSanctionKeys.byId(id), res); + await queryClient.invalidateQueries({ + queryKey: playerSanctionKeys.list(), + }); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const container: IPlayerSanctionContextProps = useMemo( + () => ({ + playerSanction, + playerSanctions, + addPlayerSanction, + getPlayerSanctionById, + getPlayerSanctionByFilter, + putPlayerSanctionById, + deletePlayerSanction, + appealPlayerSanction, + resolvePlayerSanctionAppeal, + }), + [ + playerSanction, + playerSanctions, + addPlayerSanction, + getPlayerSanctionById, + getPlayerSanctionByFilter, + putPlayerSanctionById, + deletePlayerSanction, + appealPlayerSanction, + resolvePlayerSanctionAppeal, + ] + ); + + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/playerSanction/hook/playerSanction.hook.ts b/Club12-WebClient/src/modules/playerSanction/hook/playerSanction.hook.ts new file mode 100644 index 0000000..47d64d7 --- /dev/null +++ b/Club12-WebClient/src/modules/playerSanction/hook/playerSanction.hook.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { PlayerSanctionContext } from '@/modules/playerSanction/context/playerSanction.context'; + +export const usePlayerSanction = () => { + const context = useContext(PlayerSanctionContext); + if (!context) { + throw new Error( + 'usePlayerSanction must be used within a PlayerSanctionProvider' + ); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/playerSanction/queryKeys.test.ts b/Club12-WebClient/src/modules/playerSanction/queryKeys.test.ts new file mode 100644 index 0000000..e25e48a --- /dev/null +++ b/Club12-WebClient/src/modules/playerSanction/queryKeys.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { playerSanctionKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; +import { IPlayerSanctionFiltered } from '@/modules/playerSanction/type/playerSanction.d'; + +describe('playerSanctionKeys', () => { + const id: GUID = '77777777-7777-7777-7777-777777777777'; + + it('list() returns the bare list literal with no trailing undefined', () => { + expect(playerSanctionKeys.list()).toEqual(['playerSanction', 'list']); + }); + + it('list(filter) returns the filtered list literal', () => { + const filter: IPlayerSanctionFiltered = { pageNumber: 1 }; + expect(playerSanctionKeys.list(filter)).toEqual([ + 'playerSanction', + 'list', + filter, + ]); + }); + + it('byId(id) returns the by-id literal', () => { + expect(playerSanctionKeys.byId(id)).toEqual(['playerSanction', 'byId', id]); + }); +}); diff --git a/Club12-WebClient/src/modules/playerSanction/queryKeys.ts b/Club12-WebClient/src/modules/playerSanction/queryKeys.ts new file mode 100644 index 0000000..79a4526 --- /dev/null +++ b/Club12-WebClient/src/modules/playerSanction/queryKeys.ts @@ -0,0 +1,9 @@ +import { IPlayerSanctionFiltered } from '@/modules/playerSanction/type/playerSanction.d'; + +export const playerSanctionKeys = { + list: (filter?: IPlayerSanctionFiltered) => + filter === undefined + ? (['playerSanction', 'list'] as const) + : (['playerSanction', 'list', filter] as const), + byId: (idOrSlug: string) => ['playerSanction', 'byId', idOrSlug] as const, +}; diff --git a/Club12-WebClient/src/modules/playerSanction/service/playerSanction.service.ts b/Club12-WebClient/src/modules/playerSanction/service/playerSanction.service.ts new file mode 100644 index 0000000..79299d5 --- /dev/null +++ b/Club12-WebClient/src/modules/playerSanction/service/playerSanction.service.ts @@ -0,0 +1,86 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + IAddPlayerSanction, + IAppealPlayerSanction, + IPlayerSanctionFiltered, + IPlayerSanctionResponse, + IPutPlayerSanction, + IResolveAppeal, +} from '@/modules/playerSanction/type/playerSanction'; + +/** + * Service for managing player sanction-related operations. + */ +export const playerSanctionService = { + /** + * Creates a new player sanction. + * @param {IAddPlayerSanction} playerSanction - The sanction details to create. + * @returns {Promise>} The server response. + */ + addPlayerSanction: async ( + playerSanction: IAddPlayerSanction + ): Promise> => + sendPost(routes.playerSanctions, playerSanction), + + /** + * Fetches a player sanction by its unique identifier or its slug. + * @param {string} idOrSlug - The id or slug of the player sanction to retrieve. + * @returns {Promise} A promise that resolves with the sanction response or void if not found. + */ + getPlayerSanctionById: async ( + idOrSlug: string + ): Promise> => + sendGet(`${routes.playerSanctions}/${idOrSlug}`), + + /** + * Retrieves a list of player sanctions based on a filter. + * @param {IPlayerSanctionFiltered} filter - The filter criteria for retrieving sanctions. + * @returns {Promise>>} The server response. + */ + getPlayerSanctionByFilter: async ( + filter: IPlayerSanctionFiltered + ): Promise< + AxiosResponse> + > => sendGet(`${routes.playerSanctions}/find`, withTablePageSize(filter)), + + /** + * Updates an existing player sanction by its ID. + * @param {GUID} id - The ID of the sanction to update. + * @param {IPutPlayerSanction} playerSanction - The updated sanction details. + * @returns {Promise>} The server response. + */ + putPlayerSanctionById: async ( + id: GUID, + playerSanction: IPutPlayerSanction + ): Promise> => + sendPut(`${routes.playerSanctions}/${id}`, playerSanction), + + /** + * Deletes a player sanction by its ID. + * @param {GUID} id - The ID of the sanction to delete. + * @returns {Promise>} The server response. + */ + deletePlayerSanction: async (id: GUID): Promise> => + sendDelete(`${routes.playerSanctions}/${id}`), + + appealPlayerSanction: async ( + id: GUID, + appeal: IAppealPlayerSanction + ): Promise> => + sendPut(`${routes.playerSanctions}/${id}/appeal`, appeal), + + resolvePlayerSanctionAppeal: async ( + id: GUID, + resolution: IResolveAppeal + ): Promise> => + sendPut(`${routes.playerSanctions}/${id}/appeal/resolve`, resolution), +}; diff --git a/Club12-WebClient/src/modules/playerSanction/type/playerSanction.d.ts b/Club12-WebClient/src/modules/playerSanction/type/playerSanction.d.ts new file mode 100644 index 0000000..6e78d4c --- /dev/null +++ b/Club12-WebClient/src/modules/playerSanction/type/playerSanction.d.ts @@ -0,0 +1,432 @@ +import { + FetchOptions, + Filtered, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { IMatchResponse } from '@/modules/match/type/match'; + +/** + * Context properties and methods for managing player sanctions in a sports system. + * These methods interact with the backend for creating, updating, fetching, and deleting sanctions. + * @interface IPlayerSanctionContextProps + */ +export interface IPlayerSanctionContextProps { + /** + * The current sanction applied to the player. + * @type {IPlayerSanctionResponse | null} + */ + playerSanction: IPlayerSanctionResponse | null; + + /** + * The list of sanctions applied to the match. + * @type {IPlayerSanctionResponse[] | null} + */ + playerSanctions: IPlayerSanctionResponse[] | null; + + /** + * Creates a new player sanction in the system. + * @param playerSanction The details of the sanction to be created. + * @returns A promise that resolves with the response containing the newly created sanction, or void if creation fails. + */ + addPlayerSanction( + playerSanction: IAddPlayerSanction + ): Promise; + + /** + * Fetches a player sanction by its unique identifier or its slug. + * @param {string} idOrSlug - The id or slug of the player sanction to retrieve. + * @returns {Promise} A promise that resolves with the sanction response or void if not found. + */ + getPlayerSanctionById(idOrSlug: string): Promise; + + /** + * Fetches player sanctions based on filter criteria and pagination. + * @param filter The filter criteria to apply when fetching sanctions. + * @returns A promise that resolves with a paginated response containing filtered sanctions, or void if no results are found. + */ + getPlayerSanctionByFilter( + filter: IPlayerSanctionFiltered, + options?: FetchOptions + ): Promise | void>; + + /** + * Updates a player's sanction by its ID. + * @param id The unique identifier (GUID) of the sanction to update. + * @param playerSanction The updated sanction details. + * @returns A promise that resolves with the updated sanction or void if the update fails. + */ + putPlayerSanctionById( + id: GUID, + playerSanction: IPutPlayerSanction + ): Promise; + + /** + * Deletes a player's sanction by its ID. + * @param id The unique identifier (GUID) of the sanction to delete. + * @returns A promise that resolves when the sanction is successfully deleted. + */ + deletePlayerSanction(id: GUID): Promise; + + /** + * Submits an appeal against a sanction. + */ + appealPlayerSanction( + id: GUID, + appeal: IAppealPlayerSanction + ): Promise; + + /** + * Resolves a pending appeal, recording the decision. + */ + resolvePlayerSanctionAppeal( + id: GUID, + resolution: IResolveAppeal + ): Promise; +} + +/** + * The response structure for a player sanction, including its details and associated player. + * @interface IPlayerSanctionResponse + */ +export interface IPlayerSanctionResponse { + /** + * The unique identifier of the sanction. + * @type {GUID} + */ + id: GUID; + + /** + * The duration of the sanction (unit depends on business rules, e.g., games, minutes). + * @type {number} + */ + duration: number; + + /** + * The date and time when the sanction was issued. + * @type {Date} + */ + issuedDate: Date; + + /** + * The description or reason for the sanction. + * @type {string} + */ + description: string; + + /** + * The unique, URL-friendly identifier used in sanction links. + * @type {string} + */ + slug: string; + + /** + * The kind of subject the sanction targets (HU-77): Player, Team or Staff. + * @type {SanctionSubjectType} + */ + subjectType: SanctionSubjectType; + + /** + * The number of FECHAS (jornadas) still to be served (HU-75). Zero means the + * sanction has been fully served; null when it cannot be computed. Always in + * fechas, never in calendar days. + * @type {number | null} + */ + fechasRemaining?: number | null; + + /** + * Whether the sanction is still active (HU-76): true while there are fechas + * remaining to be served. + * @type {boolean} + */ + isActive: boolean; + + /** + * The unique identifier of the player who received the sanction. Null for + * team or staff sanctions. + * @type {GUID | null} + */ + playerId: GUID | null; + + /** The sanctioned player's full name. Null for team or staff sanctions. */ + playerFullName: string | null; + + /** + * The unique identifier of the sanctioned team. Null unless this is a team + * sanction (HU-77). + * @type {GUID | null} + */ + teamId?: GUID | null; + + /** The sanctioned team's name. Null unless this is a team sanction (HU-77). */ + teamName?: string | null; + + /** The sanctioned staff member's name. Null unless this is a staff sanction (HU-77). */ + staffName?: string | null; + + matchId: GUID; + + appealStatus: SanctionAppealStatus; + + appealReason?: string | null; + + appealDate?: string | null; + + appealResolution?: string | null; + + appealResolvedDate?: string | null; +} + +/** + * The kind of subject a sanction targets (HU-77). Mirrors the backend + * `SanctionSubjectType` enum, which is serialized as a string by the API's + * JsonStringEnumConverter, so these literals are sent/received verbatim. + */ +export type SanctionSubjectType = 'Player' | 'Team' | 'Staff'; + +/** + * The appeal state of a player sanction. + */ +export type SanctionAppealStatus = + | 'None' + | 'Pending' + | 'Accepted' + | 'Rejected'; + +/** + * Request body for submitting an appeal against a sanction. + */ +export interface IAppealPlayerSanction { + reason: string; +} + +/** + * Request body for resolving a sanction appeal. + */ +export interface IResolveAppeal { + accepted: boolean; + resolution: string; +} + +/** + * The filter criteria for fetching player sanctions, including optional match and player information. + * @interface IPlayerSanctionFiltered + * @extends Filtered + */ +export interface IPlayerSanctionFiltered extends Filtered { + /** + * The unique identifier of the tournament (optional). + * @type {GUID} + */ + tournamentId?: GUID; + + /** + * The unique identifier of the division (optional). + * @type {GUID} + */ + divisionId?: GUID; + + /** + * The unique identifier of the stage (optional). + * @type {GUID} + */ + stageId?: GUID; + + /** + * The unique identifier of the team (optional). + * @type {GUID} + */ + teamId?: GUID; + + /** + * The unique identifier of the related match (optional). + * @type {GUID} + */ + matchId?: GUID; + + /** + * The unique identifier of the player (optional). + * @type {GUID} + */ + playerId?: GUID; + + /** + * The duration of the sanction (optional). + * @type {number} + */ + duration?: number; + + /** + * The date and time when the sanction was issued (optional). + * @type {Date} + */ + issuedDate?: Date; + + /** + * The description or reason for the sanction (optional). + * @type {string} + */ + description?: string; +} + +/** + * The request body structure for adding a new player sanction. + * @interface IAddPlayerSanction + */ +export interface IAddPlayerSanction { + /** + * The duration of the sanction (unit depends on business rules, e.g., games, minutes). + * @type {number} + */ + duration: number; + + /** + * The date and time when the sanction was issued. + * @type {Date} + */ + issuedDate: Date; + + /** + * The description or reason for the sanction. + * @type {string} + */ + description: string; + + /** + * The unique identifier of the related match. + * @type {GUID} + */ + matchId: GUID; + + /** + * The kind of subject the sanction targets (HU-77). Determines which of + * playerId / teamId / staffName below must be sent. + * @type {SanctionSubjectType} + */ + subjectType: SanctionSubjectType; + + /** + * The player who will receive the sanction. Required when subjectType is + * 'Player'. + * @type {GUID} + */ + playerId?: GUID; + + /** + * The team that will receive the sanction. Required when subjectType is + * 'Team' (HU-77). + * @type {GUID} + */ + teamId?: GUID; + + /** + * The staff member's name that will receive the sanction. Required when + * subjectType is 'Staff' (HU-77). + * @type {string} + */ + staffName?: string; +} + +/** + * The request body structure for updating an existing player sanction. + * @interface IPutPlayerSanction + */ +export interface IPutPlayerSanction { + /** + * The updated duration of the sanction (unit depends on business rules, e.g., games, minutes). + * @type {number} + */ + duration?: number; + + /** + * The updated description or reason for the sanction. + * @type {string} + */ + description?: string; +} + +/** + * Props para el componente InfoPlayerSanctions. + */ +export interface InfoPlayerSanctionsProps { + /** + * The unique identifier of the sanction. + * @type {GUID} + */ + id: GUID; + + /** + * Indicates whether the sanction is associated with a player. + * @type {boolean} + */ + useWithPlayer: boolean; + + /** + * The name of the sanction (optional). + * @type {string | undefined} + */ + name?: string; + + homeTeamId?: GUID; + + visitorTeamId?: GUID; +} + +export interface CreatePlayerFromMatchPlayerSanctionsProps { + homeTeamId: GUID; + + visitorTeamId: GUID; +} + +export type PlayerSanctionsSearchFilters = Pick< + IPlayerSanctionFiltered, + | 'tournamentId' + | 'divisionId' + | 'stageId' + | 'matchId' + | 'teamId' + | 'playerId' + | 'description' +>; + +export interface IPlayerSanctionCreatePageProps { + open: boolean; + onClose: () => void; + onCreated?: () => void; + + /** + * When creating a sanction from a specific match's context (e.g. the + * match detail page's Sanciones tab), pass the already-loaded match so + * the form can skip the Torneo/División/Fase/Partido cascade and go + * straight to picking the team and player involved in that match. + */ + presetMatch?: IMatchResponse | null; +} + +export interface IPlayerSanctionCreateFormState { + duration: string; + issuedDate: string; + description: string; + tournamentId: GUID | ''; + divisionId: GUID | ''; + stageId: GUID | ''; + matchId: GUID | ''; + /** The subject kind selected in the form (HU-77). */ + subjectType: SanctionSubjectType; + teamId: GUID | ''; + playerId: GUID | ''; + /** Free-text staff member name, used only when subjectType is 'Staff' (HU-77). */ + staffName: string; +} + +export interface IPlayerSanctionEditFormState { + duration: string; + description: string; +} + +export interface IPlayerSanctionDeletePageProps { + open: boolean; + sanction: IPlayerSanctionResponse | null; + onClose: () => void; + onDeleted?: () => void; +} diff --git a/Club12-WebClient/src/modules/playerSanction/utils/sanctionDisplay.test.ts b/Club12-WebClient/src/modules/playerSanction/utils/sanctionDisplay.test.ts new file mode 100644 index 0000000..a726f8f --- /dev/null +++ b/Club12-WebClient/src/modules/playerSanction/utils/sanctionDisplay.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { IPlayerSanctionResponse } from '@/modules/playerSanction/type/playerSanction.d'; +import { + formatFechasRemaining, + formatSanctionDurationFechas, + getSanctionStateLabel, + getSanctionSubjectName, + getSanctionSubjectTypeLabel, +} from '@/modules/playerSanction/utils/sanctionDisplay'; + +const guid = (value: string) => value as GUID; + +const sanction = ( + overrides: Partial +): IPlayerSanctionResponse => ({ + id: guid('sanction-1'), + duration: 2, + fechasRemaining: 1, + isActive: true, + issuedDate: new Date('2026-08-01T00:00:00Z'), + description: 'Motivo', + slug: 'sancion-1', + subjectType: 'Player', + playerId: guid('player-1'), + playerFullName: 'Ana Gómez', + teamId: null, + teamName: null, + staffName: null, + matchId: guid('match-1'), + appealStatus: 'None', + ...overrides, +}); + +describe('getSanctionSubjectName (HU-77)', () => { + it('returns the player full name for a player sanction', () => { + expect(getSanctionSubjectName(sanction({ subjectType: 'Player' }))).toBe( + 'Ana Gómez' + ); + }); + + it('returns the team name for a team sanction', () => { + const row = sanction({ + subjectType: 'Team', + playerFullName: null, + teamName: 'Club 12', + }); + expect(getSanctionSubjectName(row)).toBe('Club 12'); + expect(getSanctionSubjectTypeLabel(row)).toBe('Equipo'); + }); + + it('returns the staff name for a staff sanction', () => { + const row = sanction({ + subjectType: 'Staff', + playerFullName: null, + staffName: 'Coordinador X', + }); + expect(getSanctionSubjectName(row)).toBe('Coordinador X'); + expect(getSanctionSubjectTypeLabel(row)).toBe('Staff'); + }); +}); + +describe('sanction duration labels (HU-75)', () => { + it('labels the duration in fechas and never in días', () => { + const label = formatSanctionDurationFechas(2); + expect(label).toBe('2 fechas'); + expect(label).not.toMatch(/día/i); + }); + + it('uses the singular "fecha" for a one-fecha sanction', () => { + expect(formatSanctionDurationFechas(1)).toBe('1 fecha'); + }); + + it('shows "Permanente" for indefinite bans', () => { + expect(formatSanctionDurationFechas(999)).toBe('Permanente'); + }); + + it('formats fechas remaining, using an em dash when unknown', () => { + expect(formatFechasRemaining(0)).toBe('0 fechas'); + expect(formatFechasRemaining(1)).toBe('1 fecha'); + expect(formatFechasRemaining(null)).toBe('—'); + expect(formatFechasRemaining(undefined)).toBe('—'); + expect(formatFechasRemaining(3)).not.toMatch(/día/i); + }); +}); + +describe('getSanctionStateLabel (HU-75/HU-76)', () => { + it('reports active and served states', () => { + expect(getSanctionStateLabel({ isActive: true })).toBe('Activa'); + expect(getSanctionStateLabel({ isActive: false })).toBe('Cumplida'); + }); +}); diff --git a/Club12-WebClient/src/modules/playerSanction/utils/sanctionDisplay.ts b/Club12-WebClient/src/modules/playerSanction/utils/sanctionDisplay.ts new file mode 100644 index 0000000..b6be932 --- /dev/null +++ b/Club12-WebClient/src/modules/playerSanction/utils/sanctionDisplay.ts @@ -0,0 +1,75 @@ +import { + IPlayerSanctionResponse, + SanctionSubjectType, +} from '@/modules/playerSanction/type/playerSanction.d'; + +/** Spanish label for each sanction subject kind (HU-77). */ +export const SANCTION_SUBJECT_TYPE_LABEL: Record = { + Player: 'Jugador', + Team: 'Equipo', + Staff: 'Staff', +}; + +/** + * Sanctions recorded as a permanent/indefinite ban (e.g. expulsion) are stored + * with an arbitrarily large duration rather than a real fecha count — show it + * as "Permanente" instead of a literal, meaningless number. + */ +export const PERMANENT_SANCTION_DURATION_THRESHOLD = 999; + +type SanctionSubject = Pick< + IPlayerSanctionResponse, + 'subjectType' | 'playerFullName' | 'teamName' | 'staffName' +>; + +const EMPTY_VALUE = '—'; + +/** + * The human-readable subject of a sanction, resolved by its subjectType + * (HU-77): a player's full name, a team's name, or a staff member's name. + */ +export const getSanctionSubjectName = (sanction: SanctionSubject): string => { + switch (sanction.subjectType) { + case 'Team': + return sanction.teamName || EMPTY_VALUE; + case 'Staff': + return sanction.staffName || EMPTY_VALUE; + case 'Player': + default: + return sanction.playerFullName || EMPTY_VALUE; + } +}; + +/** The Spanish subject-kind label of a sanction (HU-77). */ +export const getSanctionSubjectTypeLabel = ( + sanction: Pick +): string => SANCTION_SUBJECT_TYPE_LABEL[sanction.subjectType] ?? EMPTY_VALUE; + +/** + * Formats a sanction's duration expressed in FECHAS (jornadas, HU-75) — never + * in calendar days. Shows "Permanente" for indefinite bans and pluralizes the + * "fecha"/"fechas" unit. + */ +export const formatSanctionDurationFechas = (duration: number): string => { + if (duration >= PERMANENT_SANCTION_DURATION_THRESHOLD) { + return 'Permanente'; + } + + return `${duration} ${duration === 1 ? 'fecha' : 'fechas'}`; +}; + +/** + * Formats the fechas still to be served (HU-75). Null (not computable) renders + * as an em dash. + */ +export const formatFechasRemaining = ( + fechasRemaining?: number | null +): string => + fechasRemaining === null || fechasRemaining === undefined + ? EMPTY_VALUE + : `${fechasRemaining} ${fechasRemaining === 1 ? 'fecha' : 'fechas'}`; + +/** The active/served state label for a sanction (HU-75/HU-76). */ +export const getSanctionStateLabel = ( + sanction: Pick +): string => (sanction.isActive ? 'Activa' : 'Cumplida'); diff --git a/Club12-WebClient/src/modules/playerStatistic/context/playerStatistic.context.tsx b/Club12-WebClient/src/modules/playerStatistic/context/playerStatistic.context.tsx new file mode 100644 index 0000000..960903f --- /dev/null +++ b/Club12-WebClient/src/modules/playerStatistic/context/playerStatistic.context.tsx @@ -0,0 +1,271 @@ +import { AxiosResponse } from 'axios'; +import { + createContext, + ReactNode, + useCallback, + useMemo, + useState, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { playerStatisticService } from '@/modules/playerStatistic/service/playerStatistic.service'; +import { + AddPlayerStatisticRequest, + IPlayerStatisticContextProps, + LoadMatchSheetRequest, + PlayerHistoryResponse, + PlayerStatisticCardResponse, + PlayerStatisticFiltered, + PlayerStatisticResponse, + PutPlayerStatisticRequest, +} from '@/modules/playerStatistic/type/playerStatistic'; +import { playerStatisticKeys } from '@/modules/playerStatistic/queryKeys'; + +export const PlayerStatisticContext = createContext< + IPlayerStatisticContextProps | undefined +>(undefined); + +export const PlayerStatisticProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [playerStatistic, setPlayerStatistic] = + useState(null); + const [playerStatistics, setPlayerStatistics] = useState< + PlayerStatisticResponse[] | null + >(null); + const [playerCard, setPlayerCard] = + useState(null); + const [playerHistory, setPlayerHistory] = + useState(null); + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addPlayerStatisticMutation = useMutation({ + mutationFn: playerStatisticService.addPlayerStatistic, + }); + + const putPlayerStatisticMutation = useMutation({ + mutationFn: ({ + statisticid, + playerStatistic, + }: { + statisticid: GUID; + playerStatistic: PutPlayerStatisticRequest; + }) => + playerStatisticService.putPlayerStatisticById( + statisticid, + playerStatistic + ), + }); + + const deletePlayerStatisticMutation = useMutation({ + mutationFn: playerStatisticService.deletePlayerStatisticById, + }); + + const loadMatchSheetMutation = useMutation({ + mutationFn: playerStatisticService.loadMatchSheet, + }); + + const addPlayerStatistic = useCallback( + async ( + newPlayerStatistic: AddPlayerStatisticRequest + ): Promise => { + try { + const response: AxiosResponse = + await addPlayerStatisticMutation.mutateAsync(newPlayerStatistic); + + if (response?.data) { + setPlayerStatistic(response.data); + queryClient.setQueryData( + ['playerStatistic', 'byId', response.data.id], + response + ); + await queryClient.invalidateQueries({ + queryKey: playerStatisticKeys.all, + }); + return response.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addPlayerStatisticMutation, handleUnknownError, queryClient] + ); + + const putPlayerStatisticById = useCallback( + async ( + statisticid: GUID, + playerStatistic: PutPlayerStatisticRequest + ): Promise => { + try { + await putPlayerStatisticMutation.mutateAsync({ + statisticid, + playerStatistic, + }); + await queryClient.invalidateQueries({ + queryKey: playerStatisticKeys.all, + }); + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putPlayerStatisticMutation, queryClient, handleUnknownError] + ); + + const getPlayerStatisticById = useCallback( + async (id: GUID): Promise => { + try { + const response: AxiosResponse = + await queryClient.fetchQuery({ + queryKey: ['playerStatistic', 'byId', id], + queryFn: async () => + await playerStatisticService.getPlayerStatisticById(id), + }); + + if (response?.data) { + setPlayerStatistic(response.data); + return response.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError, queryClient] + ); + + const getPlayerStatisticsByFilter = useCallback( + async ( + filter: PlayerStatisticFiltered + ): Promise | void> => { + try { + const response = await queryClient.fetchQuery({ + queryKey: ['playerStatistic', 'list', filter], + queryFn: async () => + await playerStatisticService.getPlayerStatisticsByFilter(filter), + }); + + if (response?.data?.items) { + setPlayerStatistics(response.data.items); + return response.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError, queryClient] + ); + + const deletePlayerStatisticById = useCallback( + async (id: GUID): Promise => { + try { + await deletePlayerStatisticMutation.mutateAsync(id); + queryClient.removeQueries({ + queryKey: ['playerStatistic', 'byId', id], + }); + await queryClient.invalidateQueries({ + queryKey: playerStatisticKeys.all, + }); + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [deletePlayerStatisticMutation, queryClient, handleUnknownError] + ); + + const loadMatchSheet = useCallback( + async ( + request: LoadMatchSheetRequest + ): Promise => { + try { + const response: AxiosResponse = + await loadMatchSheetMutation.mutateAsync(request); + + await queryClient.invalidateQueries({ + queryKey: playerStatisticKeys.all, + }); + return response.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [loadMatchSheetMutation, queryClient, handleUnknownError] + ); + + const getPlayerCard = useCallback( + async (playerId: GUID): Promise => { + try { + const response = await queryClient.fetchQuery({ + queryKey: playerStatisticKeys.card(playerId), + queryFn: async () => + await playerStatisticService.getPlayerCard(playerId), + }); + + if (response?.data) { + setPlayerCard(response.data); + return response.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError, queryClient] + ); + + const getPlayerHistory = useCallback( + async (playerId: GUID): Promise => { + try { + const response = await queryClient.fetchQuery({ + queryKey: playerStatisticKeys.history(playerId), + queryFn: async () => + await playerStatisticService.getPlayerHistory(playerId), + }); + + if (response?.data) { + setPlayerHistory(response.data); + return response.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError, queryClient] + ); + + const container = useMemo( + () => ({ + playerStatistic, + playerStatistics, + playerCard, + playerHistory, + addPlayerStatistic, + putPlayerStatisticById, + getPlayerStatisticById, + getPlayerStatisticsByFilter, + deletePlayerStatisticById, + loadMatchSheet, + getPlayerCard, + getPlayerHistory, + }), + [ + addPlayerStatistic, + deletePlayerStatisticById, + getPlayerCard, + getPlayerHistory, + getPlayerStatisticById, + getPlayerStatisticsByFilter, + loadMatchSheet, + playerCard, + playerHistory, + playerStatistic, + playerStatistics, + putPlayerStatisticById, + ] + ); + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/playerStatistic/hook/playerStatistic.hook.ts b/Club12-WebClient/src/modules/playerStatistic/hook/playerStatistic.hook.ts new file mode 100644 index 0000000..538b5dd --- /dev/null +++ b/Club12-WebClient/src/modules/playerStatistic/hook/playerStatistic.hook.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react' +import { PlayerStatisticContext } from '@/modules/playerStatistic/context/playerStatistic.context' + +export const usePlayerStatistic = () => { + const context = useContext(PlayerStatisticContext) + if (!context) { + throw new Error( + 'usePlayerStatistic must be used within a PlayerStatisticProvider' + ) + } + return context +} diff --git a/Club12-WebClient/src/modules/playerStatistic/queryKeys.test.ts b/Club12-WebClient/src/modules/playerStatistic/queryKeys.test.ts new file mode 100644 index 0000000..e7200f2 --- /dev/null +++ b/Club12-WebClient/src/modules/playerStatistic/queryKeys.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { playerStatisticKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; + +const playerId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as GUID; + +describe('playerStatisticKeys', () => { + it('all returns the bare invalidate-all literal', () => { + expect(playerStatisticKeys.all).toEqual(['playerStatistic']); + }); + + it('card(playerId) returns the per-player card literal', () => { + expect(playerStatisticKeys.card(playerId)).toEqual([ + 'playerStatistic', + 'card', + playerId, + ]); + }); + + it('history(playerId) returns the per-player history literal', () => { + expect(playerStatisticKeys.history(playerId)).toEqual([ + 'playerStatistic', + 'history', + playerId, + ]); + }); +}); diff --git a/Club12-WebClient/src/modules/playerStatistic/queryKeys.ts b/Club12-WebClient/src/modules/playerStatistic/queryKeys.ts new file mode 100644 index 0000000..eb6c8c5 --- /dev/null +++ b/Club12-WebClient/src/modules/playerStatistic/queryKeys.ts @@ -0,0 +1,10 @@ +import { GUID } from '@/modules/core/types/types'; + +export const playerStatisticKeys = { + all: ['playerStatistic'] as const, + /** A player's statistic card (HU-87). */ + card: (playerId: GUID) => ['playerStatistic', 'card', playerId] as const, + /** A player's cross-season history (HU-88). */ + history: (playerId: GUID) => + ['playerStatistic', 'history', playerId] as const, +}; diff --git a/Club12-WebClient/src/modules/playerStatistic/service/playerStatistic.service.test.ts b/Club12-WebClient/src/modules/playerStatistic/service/playerStatistic.service.test.ts new file mode 100644 index 0000000..6e5748e --- /dev/null +++ b/Club12-WebClient/src/modules/playerStatistic/service/playerStatistic.service.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { LoadMatchSheetRequest } from '@/modules/playerStatistic/type/playerStatistic'; + +vi.mock('@/modules/core/utils/axiosUtils', () => ({ + sendGet: vi.fn(), + sendPost: vi.fn(() => Promise.resolve({ data: [] })), + sendPut: vi.fn(), + sendDelete: vi.fn(), +})); + +import { sendPost } from '@/modules/core/utils/axiosUtils'; +import { playerStatisticService } from '@/modules/playerStatistic/service/playerStatistic.service'; + +const guid = (value: string) => value as GUID; + +describe('playerStatisticService.loadMatchSheet', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('POSTs the whole team sheet to player-statistics/match-sheet', async () => { + const request: LoadMatchSheetRequest = { + matchId: guid('aaaa-bbbb-cccc-dddd-eeee'), + teamId: guid('1111-2222-3333-4444-5555'), + scores: [{ playerId: guid('6666-7777-8888-9999-0000'), points: 10 }], + }; + + await playerStatisticService.loadMatchSheet(request); + + expect(sendPost).toHaveBeenCalledWith( + 'player-statistics/match-sheet', + request + ); + }); +}); diff --git a/Club12-WebClient/src/modules/playerStatistic/service/playerStatistic.service.ts b/Club12-WebClient/src/modules/playerStatistic/service/playerStatistic.service.ts new file mode 100644 index 0000000..60780c5 --- /dev/null +++ b/Club12-WebClient/src/modules/playerStatistic/service/playerStatistic.service.ts @@ -0,0 +1,109 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + AddPlayerStatisticRequest, + LoadMatchSheetRequest, + PlayerHistoryResponse, + PlayerStatisticCardResponse, + PlayerStatisticFiltered, + PlayerStatisticResponse, + PutPlayerStatisticRequest, +} from '@/modules/playerStatistic/type/playerStatistic'; + +/** + * Service for managing player statistics. + */ +export const playerStatisticService = { + /** + * Adds a new player statistic. + * @param {AddPlayerStatisticRequest} playerStatistic - The player statistic details to add. + * @returns {Promise>} The server response. + */ + addPlayerStatistic: async ( + playerStatistic: AddPlayerStatisticRequest + ): Promise> => + await sendPost(routes.playerStatistics, playerStatistic), + + /** + * Loads a whole team's match sheet (planilla) for a match in one call + * (HU-71). The listed players' points must add up to the team's final score; + * the backend returns 409 with the difference otherwise. + * @param {LoadMatchSheetRequest} request - The match, team and per-player points. + * @returns {Promise>} The persisted statistics. + */ + loadMatchSheet: async ( + request: LoadMatchSheetRequest + ): Promise> => + await sendPost(`${routes.playerStatistics}/match-sheet`, request), + + /** + * Updates an existing player statistic. + * @param {string} statisticId - The unique identifier of the player statistic to update. + * @param {PutPlayerStatisticRequest} playerStatistic - The updated player statistic details. + * @returns {Promise>} The server response. + */ + putPlayerStatisticById: async ( + statisticid: GUID, + playerStatistic: PutPlayerStatisticRequest + ): Promise> => + await sendPut(`${routes.playerStatistics}/${statisticid}`, playerStatistic), + + /** + * Retrieves a player statistic by its ID. + * @param {string} id - The ID of the player statistic to retrieve. + * @returns {Promise>} The server response. + */ + getPlayerStatisticById: async ( + id: GUID + ): Promise> => + await sendGet(`${routes.playerStatistics}/${id}`), + + /** + * Retrieves player statistics based on filters. + * @param {PlayerStatisticFiltered} filter - The filter criteria to apply. + * @returns {Promise>>} The server response. + */ + getPlayerStatisticsByFilter: async ( + filter: PlayerStatisticFiltered + ): Promise< + AxiosResponse> + > => await sendGet(routes.playerStatistics, withTablePageSize(filter)), + + /** + * Deletes a player statistic by its ID. + * @param {string} id - The ID of the player statistic to delete. + * @returns {Promise>} The server response. + */ + deletePlayerStatisticById: async (id: GUID): Promise> => + await sendDelete(`${routes.playerStatistics}/${id}`), + + /** + * Retrieves a player's statistic card (HU-87): total and average points and + * games played, per season and overall. + * @param {GUID} playerId - The player's stable id. + * @returns {Promise>} The card. + */ + getPlayerCard: async ( + playerId: GUID + ): Promise> => + await sendGet(`${routes.statistics}/players/${playerId}/card`), + + /** + * Retrieves a player's cross-season history (HU-88): per season, the team, + * their stats and their sanctions. + * @param {GUID} playerId - The player's stable id. + * @returns {Promise>} The history. + */ + getPlayerHistory: async ( + playerId: GUID + ): Promise> => + await sendGet(`${routes.statistics}/players/${playerId}/history`), +}; diff --git a/Club12-WebClient/src/modules/playerStatistic/type/playerStatistic.d.ts b/Club12-WebClient/src/modules/playerStatistic/type/playerStatistic.d.ts new file mode 100644 index 0000000..b7fbc5c --- /dev/null +++ b/Club12-WebClient/src/modules/playerStatistic/type/playerStatistic.d.ts @@ -0,0 +1,325 @@ +import { + Filtered, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; + +/** + * Context properties and methods for managing player statistics in a sports system. + * These methods allow for creating, updating, fetching, and deleting player statistics. + * @interface IPlayerStatisticContextProps + */ +export interface IPlayerStatisticContextProps { + playerStatistic: PlayerStatisticResponse | null; + playerStatistics: PlayerStatisticResponse[] | null; + playerCard: PlayerStatisticCardResponse | null; + playerHistory: PlayerHistoryResponse | null; + + /** + * Fetches a player's statistic card (HU-87): total/average points and games + * played, per season and overall. + * @param playerId The player's stable id. + */ + getPlayerCard( + playerId: GUID + ): Promise; + + /** + * Fetches a player's cross-season history (HU-88): per season, the team, + * their stats and their sanctions. + * @param playerId The player's stable id. + */ + getPlayerHistory(playerId: GUID): Promise; + + /** + * Adds a new player statistic. + * @param playerStatistic The details of the player statistic to add. + * @returns A promise that resolves with the response containing the newly added player statistic. + */ + addPlayerStatistic( + playerStatistic: AddPlayerStatisticRequest + ): Promise; + + /** + * Updates an existing player statistic. + * @param {string} statisticId - The unique identifier of the player statistic to update. + * @param playerStatistic The updated player statistic details. + * @returns A promise that resolves when the player statistic is successfully updated. + */ + putPlayerStatisticById( + statisticid: GUID, + playerStatistic: PutPlayerStatisticRequest + ): Promise; + + /** + * Fetches a player statistic by its ID. + * @param id The ID of the player statistic to fetch. + * @returns A promise that resolves with the player statistic details. + */ + getPlayerStatisticById(id: GUID): Promise; + + /** + * Fetches player statistics based on filters and pagination. + * @param filter The filters to apply when fetching player statistics. + * @returns A promise that resolves with the paginated statistics list. + */ + getPlayerStatisticsByFilter( + filter: PlayerStatisticFiltered + ): Promise | void>; + + /** + * Deletes a player statistic by its ID. + * @param id The ID of the player statistic to delete. + * @returns A promise that resolves when the player statistic is successfully deleted. + */ + deletePlayerStatisticById(id: GUID): Promise; + + /** + * Loads a whole team's scoring sheet (planilla) for a match in one coherent + * operation (HU-71). The listed players' points must add up to the team's + * final score; otherwise the backend saves nothing and returns 409. + * @param request The match, team, and per-player points. + * @returns A promise that resolves with the persisted Points statistics, or + * void on error (the error message is surfaced globally). + */ + loadMatchSheet( + request: LoadMatchSheetRequest + ): Promise; +} + +export type StatisticType = 'Points' | 'Assists'; + +/** + * One season's scoring line inside a player's statistic card (HU-87). A + * "season" is the calendar year of the tournament's start date (HU-85). + */ +export interface SeasonStatLine { + season: number; + totalPoints: number; + gamesPlayed: number; + /** Points per game played that season, rounded to two decimals. */ + averagePoints: number; +} + +/** + * A player's individual statistic card (HU-87): total and average points and + * games played, both overall and broken down per season (most recent first). + */ +export interface PlayerStatisticCardResponse { + playerId: GUID; + fullName: string; + totalPoints: number; + gamesPlayed: number; + /** Overall points per game played, rounded to two decimals. */ + averagePoints: number; + seasons: SeasonStatLine[]; +} + +/** + * A single sanction the player received during a given season (HU-88). + */ +export interface PlayerHistorySanction { + sanctionId: GUID; + description: string; + /** Length in fechas (matchdays), per HU-75. */ + duration: number; + issuedDate: string; + matchId: GUID; +} + +/** + * One row of a player's trajectory (HU-88): for a given season/tournament, the + * team they were registered to, their scoring stats there, and the sanctions + * they received. + */ +export interface PlayerHistorySeason { + season: number; + tournamentId: GUID; + tournamentName: string; + teamId: GUID; + teamName: string; + totalPoints: number; + gamesPlayed: number; + sanctions: PlayerHistorySanction[]; +} + +/** + * A player's full cross-season trajectory (HU-88): one entry per season the + * player was registered, most recent season first. + */ +export interface PlayerHistoryResponse { + playerId: GUID; + fullName: string; + seasons: PlayerHistorySeason[]; +} + +export interface PlayerStatisticFiltered extends Filtered { + playerId?: GUID; + teamId?: GUID; + matchId?: GUID; + type?: StatisticType; +} + +/** + * The request body structure for adding a new player statistic. + * @interface AddPlayerStatisticRequest + */ +export interface AddPlayerStatisticRequest { + /** + * The value of the player statistic (e.g., number of goals, points, etc.). + * @type {number} + */ + value: number; + + /** + * The ID of the match in which the statistic was recorded. + * @type {string} + */ + matchId: GUID; + + /** + * The ID of the player for whom the statistic is recorded. + * @type {string} + */ + playerId: GUID; + + /** + * The type of statistic (Points or Assists). + * @type {StatisticType} + */ + type: StatisticType; +} + +/** + * The response structure for a player statistic. + * @interface PlayerStatisticResponse + */ +export interface PlayerStatisticResponse { + /** + * The unique identifier of the player statistic. + * @type {string} + */ + id: GUID; + + /** + * The ID of the player for whom the statistic is recorded. + * @type {string} + */ + playerId: GUID; + + /** + * The value of the player statistic. + * @type {number} + */ + value: number; + + /** + * The ID of the match in which the statistic was recorded. + * @type {string} + */ + matchId: GUID; + + /** + * The type of statistic (Points or Assists). + * @type {StatisticType} + */ + type: StatisticType; + + /** + * The date of the associated match, for display without a separate lookup. + * @type {string | null} + */ + matchDate: string | null; +} + +/** + * A single player's points within a team's match sheet (HU-71). + * @interface PlayerScoreEntry + */ +export interface PlayerScoreEntry { + /** + * The player who scored. + * @type {GUID} + */ + playerId: GUID; + + /** + * The points the player scored in the match (may be zero). + * @type {number} + */ + points: number; +} + +/** + * The request body structure for loading a whole team's match sheet + * (planilla) in one call (HU-71). The sum of `scores` points must equal the + * team's final score for the match. + * @interface LoadMatchSheetRequest + */ +export interface LoadMatchSheetRequest { + /** + * The match whose sheet is being loaded. + * @type {GUID} + */ + matchId: GUID; + + /** + * The team (home or visitor) whose players are being loaded. + * @type {GUID} + */ + teamId: GUID; + + /** + * The per-player points for the team. + * @type {PlayerScoreEntry[]} + */ + scores: PlayerScoreEntry[]; +} + +/** + * The request body structure for updating a player statistic. + * @interface PutPlayerStatisticRequest + */ +export interface PutPlayerStatisticRequest { + /** + * The updated value of the player statistic. + * @type {number} + */ + value: number; +} + +export type PlayerStatisticsViewMode = 'team' | 'player'; + +export interface IPlayerStatisticCreatePageProps { + open: boolean; + onClose: () => void; + onCreated?: () => void; +} + +export interface IPlayerStatisticCreateFormState { + value: string; + type: StatisticType; + tournamentId: GUID | ''; + divisionId: GUID | ''; + stageId: GUID | ''; + matchId: GUID | ''; + teamId: GUID | ''; + playerId: GUID | ''; +} + +export interface ITeamStatisticTableRow { + id: string; + teamName: string; + playersWithScore: number; + records: number; + totalScore: number; +} + +export interface IPlayerStatisticTableRow { + id: GUID; + playerId: GUID; + playerName: string; + teamName: string; + records: number; + totalScore: number; +} diff --git a/Club12-WebClient/src/modules/playerStatistic/utils/playerStatisticDisplay.ts b/Club12-WebClient/src/modules/playerStatistic/utils/playerStatisticDisplay.ts new file mode 100644 index 0000000..4711db2 --- /dev/null +++ b/Club12-WebClient/src/modules/playerStatistic/utils/playerStatisticDisplay.ts @@ -0,0 +1,11 @@ +import { StatisticType } from '@/modules/playerStatistic/type/playerStatistic.d'; + +/** + * Canonical Spanish display labels for every statistic type. Co-located here + * so every view (player detail, team detail, create-statistic form) shows + * the same wording instead of each screen inventing its own. + */ +export const STATISTIC_TYPE_LABELS: Record = { + Points: 'Puntos', + Assists: 'Asistencias', +}; diff --git a/Club12-WebClient/src/modules/playoff/bracketAdapter.test.ts b/Club12-WebClient/src/modules/playoff/bracketAdapter.test.ts new file mode 100644 index 0000000..a44e07c --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/bracketAdapter.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it } from 'vitest'; +import { toLibraryMatches, libraryRoundLabels } from '@/modules/playoff/bracketAdapter'; +import { BracketModel } from '@/modules/playoff/type/bracket.d'; +import { StageType } from '@/modules/stage/type/stage'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { ITeamMatchResponse } from '@/modules/team/type/team.d'; +import { GUID } from '@/modules/core/types/types'; + +const guid = (seed: string): GUID => `${seed}-0000-0000-0000-000000000000` as GUID; + +const makeTeam = ( + overrides: Partial & { id: GUID; name: string } +): ITeamMatchResponse => ({ + logoUrl: '', + score: 0, + players: [], + scorers: [], + ...overrides, +}); + +const makeMatch = (overrides: Partial & { id: GUID; stageId: GUID }): IMatchResponse => ({ + matchDate: '2026-01-01T18:00:00Z', + matchType: 'Playoff' as IMatchResponse['matchType'], + slug: '', + homeTeam: null, + visitorTeam: null, + isFinished: false, + winningTeamId: null, + winningTeamName: null, + venue: null, + ...overrides, +}); + +describe('toLibraryMatches', () => { + it('maps an unambiguous inferred edge to nextMatchId', () => { + const teamA = guid('team-a'); + const qfMatch = makeMatch({ + id: guid('m-qf'), + stageId: guid('qf'), + winningTeamId: teamA, + homeTeam: makeTeam({ id: teamA, name: 'A', score: 3 }), + visitorTeam: makeTeam({ id: guid('team-b'), name: 'B', score: 1 }), + isFinished: true, + }); + const sfMatch = makeMatch({ + id: guid('m-sf'), + stageId: guid('sf'), + homeTeam: makeTeam({ id: teamA, name: 'A' }), + visitorTeam: null, + }); + + const model: BracketModel = { + rounds: [ + { stageId: guid('qf'), stageType: StageType.QuarterFinal, matches: [qfMatch] }, + { stageId: guid('sf'), stageType: StageType.SemiFinal, matches: [sfMatch] }, + ], + edges: [{ fromMatchId: qfMatch.id, toMatchId: sfMatch.id }], + }; + + const matches = toLibraryMatches(model); + + expect(matches.find(m => m.id === qfMatch.id)?.nextMatchId).toBe(sfMatch.id); + }); + + it('falls back to positional pairing (index / 2) when no edge was inferred yet', () => { + const sfMatchA = makeMatch({ id: guid('m-sf-a'), stageId: guid('sf') }); + const sfMatchB = makeMatch({ id: guid('m-sf-b'), stageId: guid('sf') }); + const finalMatch = makeMatch({ id: guid('m-final'), stageId: guid('final') }); + + const model: BracketModel = { + rounds: [ + { stageId: guid('sf'), stageType: StageType.SemiFinal, matches: [sfMatchA, sfMatchB] }, + { stageId: guid('final'), stageType: StageType.Final, matches: [finalMatch] }, + ], + edges: [], + }; + + const matches = toLibraryMatches(model); + + expect(matches.find(m => m.id === sfMatchA.id)?.nextMatchId).toBe(finalMatch.id); + expect(matches.find(m => m.id === sfMatchB.id)?.nextMatchId).toBe(finalMatch.id); + }); + + it('sets nextMatchId to null for the last round', () => { + const finalMatch = makeMatch({ id: guid('m-final'), stageId: guid('final') }); + + const model: BracketModel = { + rounds: [{ stageId: guid('final'), stageType: StageType.Final, matches: [finalMatch] }], + edges: [], + }; + + const matches = toLibraryMatches(model); + + expect(matches[0].nextMatchId).toBeNull(); + }); + + it('excludes thirdPlace from the library matches entirely', () => { + const finalMatch = makeMatch({ id: guid('m-final'), stageId: guid('final') }); + const thirdMatch = makeMatch({ id: guid('m-third'), stageId: guid('third') }); + + const model: BracketModel = { + rounds: [{ stageId: guid('final'), stageType: StageType.Final, matches: [finalMatch] }], + thirdPlace: { stageId: guid('third'), stageType: StageType.ThirdPlace, matches: [thirdMatch] }, + edges: [], + }; + + const matches = toLibraryMatches(model); + + expect(matches).toHaveLength(1); + expect(matches.find(m => m.id === thirdMatch.id)).toBeUndefined(); + }); + + it('skips rounds with zero matches so round numbering stays contiguous', () => { + const finalMatch = makeMatch({ id: guid('m-final'), stageId: guid('final') }); + + const model: BracketModel = { + rounds: [ + { stageId: guid('qf'), stageType: StageType.QuarterFinal, matches: [] }, + { stageId: guid('final'), stageType: StageType.Final, matches: [finalMatch] }, + ], + edges: [], + }; + + const matches = toLibraryMatches(model); + + expect(matches).toHaveLength(1); + expect(libraryRoundLabels(model)).toEqual(['FINAL']); + }); + + it('synthesizes one placeholder match for a later round whose stage exists but has no visible match rows yet, so every earlier match still has exactly one place to connect into', () => { + // Regression test: SingleEliminationBracket only renders one match when + // more than one match has nextMatchId: null (a "multi-root" tree) — + // confirmed against the installed library in manual testing. A later + // round with zero matches (e.g. the Final stage exists but hasn't been + // seeded yet, or got cut off by an upstream paginated fetch) must never + // leave more than one match in the whole bracket with a null + // nextMatchId, or every match but one silently disappears. + const sfMatchA = makeMatch({ id: guid('m-sf-a'), stageId: guid('sf') }); + const sfMatchB = makeMatch({ id: guid('m-sf-b'), stageId: guid('sf') }); + const finalStageId = guid('final'); + + const model: BracketModel = { + rounds: [ + { stageId: guid('sf'), stageType: StageType.SemiFinal, matches: [sfMatchA, sfMatchB] }, + { stageId: finalStageId, stageType: StageType.Final, matches: [] }, + ], + edges: [], + }; + + const matches = toLibraryMatches(model); + + // Both semifinal matches survive... + expect(matches.map(m => m.id)).toEqual( + expect.arrayContaining([sfMatchA.id, sfMatchB.id]) + ); + // ...exactly one match is the root (nextMatchId: null)... + expect(matches.filter(m => m.nextMatchId === null)).toHaveLength(1); + // ...and both SF matches converge into that same synthesized placeholder. + expect(matches.find(m => m.id === sfMatchA.id)?.nextMatchId).toBe(finalStageId); + expect(matches.find(m => m.id === sfMatchB.id)?.nextMatchId).toBe(finalStageId); + expect(libraryRoundLabels(model)).toEqual(['SEMIFINAL', 'FINAL']); + }); + + it("carries a round's legsByMatchId entry as the library match's legs, for multi-leg ties", () => { + const tieMatch = makeMatch({ id: guid('tie'), stageId: guid('sf') }); + const leg1 = makeMatch({ id: guid('leg1'), stageId: guid('sf') }); + const leg2 = makeMatch({ id: guid('leg2'), stageId: guid('sf') }); + + const model: BracketModel = { + rounds: [ + { + stageId: guid('sf'), + stageType: StageType.SemiFinal, + matches: [tieMatch], + legsByMatchId: new Map([[tieMatch.id, [leg1, leg2]]]), + }, + ], + edges: [], + }; + + const [match] = toLibraryMatches(model); + + expect(match.legs).toEqual([leg1, leg2]); + }); + + it('leaves legs undefined when the round carries no legsByMatchId entry for a match', () => { + const finalMatch = makeMatch({ id: guid('m-final'), stageId: guid('final') }); + + const model: BracketModel = { + rounds: [{ stageId: guid('final'), stageType: StageType.Final, matches: [finalMatch] }], + edges: [], + }; + + const [match] = toLibraryMatches(model); + + expect(match.legs).toBeUndefined(); + }); + + it('keeps a decided bye in the returned array so sibling row/column positions stay correct', () => { + // Regression: @g-loot/react-tournament-brackets computes every match's + // Y position, and every connector line, purely from its row/column + // INDEX — it assumes round N always has exactly twice round N+1's match + // count (calculate-match-position.js). Dropping a bye out of the array + // here shifts every later sibling's index and desyncs the whole + // column's geometry from the actual bracket shape (cards land at the + // wrong row, connectors point at the wrong pair) — a bye must stay in + // the array; it's hidden at render time instead (BracketMatchLibraryAdapter). + const byeMatch = makeMatch({ + id: guid('m-bye'), + stageId: guid('qf'), + isFinished: true, + homeTeam: makeTeam({ id: guid('team-a'), name: 'A' }), + visitorTeam: null, + winningTeamId: guid('team-a'), + winningTeamName: 'A', + }); + const realMatch = makeMatch({ + id: guid('m-real'), + stageId: guid('qf'), + homeTeam: makeTeam({ id: guid('team-b'), name: 'B' }), + visitorTeam: makeTeam({ id: guid('team-c'), name: 'C' }), + }); + + const model: BracketModel = { + rounds: [{ stageId: guid('qf'), stageType: StageType.QuarterFinal, matches: [byeMatch, realMatch] }], + edges: [], + }; + + const matches = toLibraryMatches(model); + + expect(matches).toHaveLength(2); + expect(matches.map(m => m.id)).toEqual([byeMatch.id, realMatch.id]); + }); + + it('flags the child match to hide its connector on the side a decided bye sibling occupied', () => { + const byeMatch = makeMatch({ + id: guid('m-bye'), + stageId: guid('qf'), + isFinished: true, + homeTeam: makeTeam({ id: guid('team-a'), name: 'A' }), + visitorTeam: null, + winningTeamId: guid('team-a'), + winningTeamName: 'A', + }); + const realMatch = makeMatch({ + id: guid('m-real'), + stageId: guid('qf'), + homeTeam: makeTeam({ id: guid('team-b'), name: 'B' }), + visitorTeam: makeTeam({ id: guid('team-c'), name: 'C' }), + }); + const sfMatch = makeMatch({ id: guid('m-sf'), stageId: guid('sf') }); + + const model: BracketModel = { + rounds: [ + { stageId: guid('qf'), stageType: StageType.QuarterFinal, matches: [byeMatch, realMatch] }, + { stageId: guid('sf'), stageType: StageType.SemiFinal, matches: [sfMatch] }, + ], + edges: [], + }; + + const matches = toLibraryMatches(model); + const sf = matches.find(m => m.id === sfMatch.id); + + expect(sf?.hideTopConnector).toBe(true); + expect(sf?.hideBottomConnector).toBe(false); + }); + + it('hides both connectors when BOTH siblings feeding a match were decided byes', () => { + // Deep enough into a bracket, a pairing can have both sides decided by + // a walkover (two teams that each drew a bye into the same slot) — the + // child match itself is real (they play each other for real), but + // NEITHER of its two sources has a rendered card, so both incoming + // connectors would dangle without this. + const byeA = makeMatch({ + id: guid('m-bye-a'), + stageId: guid('ro16'), + isFinished: true, + homeTeam: makeTeam({ id: guid('team-a'), name: 'A' }), + visitorTeam: null, + winningTeamId: guid('team-a'), + winningTeamName: 'A', + }); + const byeB = makeMatch({ + id: guid('m-bye-b'), + stageId: guid('ro16'), + isFinished: true, + homeTeam: makeTeam({ id: guid('team-b'), name: 'B' }), + visitorTeam: null, + winningTeamId: guid('team-b'), + winningTeamName: 'B', + }); + const qfMatch = makeMatch({ id: guid('m-qf'), stageId: guid('qf') }); + + const model: BracketModel = { + rounds: [ + { stageId: guid('ro16'), stageType: StageType.RoundOf16, matches: [byeA, byeB] }, + { stageId: guid('qf'), stageType: StageType.QuarterFinal, matches: [qfMatch] }, + ], + edges: [], + }; + + const matches = toLibraryMatches(model); + const qf = matches.find(m => m.id === qfMatch.id); + + expect(qf?.hideTopConnector).toBe(true); + expect(qf?.hideBottomConnector).toBe(true); + }); + + it('leaves both connectors visible when neither sibling is a bye', () => { + const matchA = makeMatch({ + id: guid('m-a'), + stageId: guid('qf'), + homeTeam: makeTeam({ id: guid('team-a'), name: 'A' }), + visitorTeam: makeTeam({ id: guid('team-b'), name: 'B' }), + }); + const matchB = makeMatch({ + id: guid('m-b'), + stageId: guid('qf'), + homeTeam: makeTeam({ id: guid('team-c'), name: 'C' }), + visitorTeam: makeTeam({ id: guid('team-d'), name: 'D' }), + }); + const sfMatch = makeMatch({ id: guid('m-sf'), stageId: guid('sf') }); + + const model: BracketModel = { + rounds: [ + { stageId: guid('qf'), stageType: StageType.QuarterFinal, matches: [matchA, matchB] }, + { stageId: guid('sf'), stageType: StageType.SemiFinal, matches: [sfMatch] }, + ], + edges: [], + }; + + const matches = toLibraryMatches(model); + const sf = matches.find(m => m.id === sfMatch.id); + + expect(sf?.hideTopConnector).toBe(false); + expect(sf?.hideBottomConnector).toBe(false); + }); + + it('carries the raw match and per-side participant ids for a TBD slot', () => { + const finalMatch = makeMatch({ + id: guid('m-final'), + stageId: guid('final'), + homeTeam: null, + visitorTeam: null, + }); + + const model: BracketModel = { + rounds: [{ stageId: guid('final'), stageType: StageType.Final, matches: [finalMatch] }], + edges: [], + }; + + const [match] = toLibraryMatches(model); + + expect(match.raw).toBe(finalMatch); + expect(match.participants.map(p => p.id)).toEqual([ + `${finalMatch.id}:home`, + `${finalMatch.id}:visitor`, + ]); + }); +}); diff --git a/Club12-WebClient/src/modules/playoff/bracketAdapter.ts b/Club12-WebClient/src/modules/playoff/bracketAdapter.ts new file mode 100644 index 0000000..e236ab0 --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/bracketAdapter.ts @@ -0,0 +1,222 @@ +import type { LibraryMatch, LibraryParticipant } from '@/modules/playoff/type/gLootBracketTypes.d'; +import { GUID } from '@/modules/core/types/types'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { BracketModel, BracketRound } from '@/modules/playoff/type/bracket.d'; +import { bracketParticipantId, bracketTeamLabel, isBracketBye, isBracketMatchWinner } from '@/modules/playoff/matchStatus'; +import { translateStageType } from '@/modules/core/utils/translateStageType'; + +/** @g-loot/react-tournament-brackets match state for a decided seeding walkover. */ +const STATE_WALKOVER = 'WALK_OVER'; +/** @g-loot/react-tournament-brackets match state for a slot still missing at least one participant. */ +const STATE_PENDING_PARTICIPANTS = 'NO_PARTY'; +/** @g-loot/react-tournament-brackets match state for a completed, decided match or series. */ +const STATE_DONE = 'DONE'; +/** @g-loot/react-tournament-brackets match state for a fully-seeded match awaiting its result. */ +const STATE_SCHEDULED = 'SCORE_DONE'; + +/** + * A library `Match` carrying this app's original `IMatchResponse` alongside + * it, so the custom match component ({@link BracketMatchLibraryAdapter}) + * can render the app's own card (team logos, series breakdown) instead of + * the library's built-in one. + */ +export interface PlayoffLibraryMatch extends LibraryMatch { + id: GUID; + nextMatchId: GUID | null; + raw: IMatchResponse; + /** + * The individual legs this node aggregates (chronologically ordered), + * when `raw` collapses more than one raw `Match` row between the same + * two teams — see `buildBracket.ts`'s tie grouping. Undefined for the + * common case of one match per bracket slot. + */ + legs?: IMatchResponse[]; + /** + * True when this match's incoming top/bottom connector (drawn by the + * library purely from row position, independent of whether a card is + * actually rendered there — see `BracketMatchLibraryAdapter`) comes from + * a sibling that was a decided bye. That sibling's card is hidden, so + * the library would otherwise draw a line hanging from empty space with + * nothing at its far end — see `PlayoffBracket`'s render-time patch. + */ + hideTopConnector?: boolean; + hideBottomConnector?: boolean; +} + +const toParticipant = (match: IMatchResponse, side: 'home' | 'visitor'): LibraryParticipant => { + const team = side === 'home' ? match.homeTeam : match.visitorTeam; + return { + id: bracketParticipantId(match, side), + name: bracketTeamLabel(team, match), + isWinner: isBracketMatchWinner(match, team?.id), + resultText: match.isFinished && team ? String(team.score) : null, + }; +}; + +const matchState = (match: IMatchResponse): string => { + if (isBracketBye(match)) return STATE_WALKOVER; + if (!match.homeTeam || !match.visitorTeam) return STATE_PENDING_PARTICIPANTS; + return match.isFinished ? STATE_DONE : STATE_SCHEDULED; +}; + +/** + * Resolves the id of the library match a given round match should connect + * into. Prefers buildBracket's own inferred edge — only ever present once a + * winner is decided and the mapping is unambiguous (see + * buildBracket.ts's "graceful degradation" rules). When that's absent (an + * upcoming round whose participants aren't decided yet), falls back to + * positional pairing (`index / 2` into the next round), matching the + * backend's classic bracket seed order (see + * `IStageContextProps.seedKnockoutStage`). Without this fallback, the + * library — which derives its entire column layout by walking + * `nextMatchId` backward from the final match — would silently drop any + * not-yet-decided match from the rendered tree. + */ +const resolveNextMatchId = ( + matchIndex: number, + edgeTarget: GUID | undefined, + nextRound: BracketRound | undefined +): GUID | null => { + if (edgeTarget) return edgeTarget; + if (!nextRound || nextRound.matches.length === 0) return null; + + const fallbackIndex = Math.min(Math.floor(matchIndex / 2), nextRound.matches.length - 1); + return nextRound.matches[fallbackIndex]?.id ?? null; +}; + +/** + * A stand-in `IMatchResponse` for a round whose stage exists but has no + * visible match rows (see {@link effectiveRounds}). Renders as a plain TBD + * slot via the normal `BracketMatchNode` — no `id` collision risk since a + * stage id is never reused as a match id. + */ +const placeholderRawMatch = (round: BracketRound): IMatchResponse => ({ + id: round.stageId, + matchDate: '', + matchType: 'Playoff' as IMatchResponse['matchType'], + slug: '', + homeTeam: null, + visitorTeam: null, + isFinished: false, + winningTeamId: null, + winningTeamName: null, + venue: null, + stageId: round.stageId, +}); + +/** + * Rounds to actually feed the library, in bracket order. A single-elimination + * bracket, by construction, always converges to exactly one final match; the + * library's column layout depends on that (it walks `nextMatchId` backward + * from the one match that has none). If a later round's stage exists but + * currently has zero match rows — not yet generated, or simply not + * returned by a paginated fetch — dropping it outright would leave *every* + * match in the round before it with nowhere to point, i.e. multiple + * `nextMatchId: null` "roots". Empirically, `SingleEliminationBracket` + * doesn't render a proper multi-root forest — it silently drops every + * root but one. So instead of dropping an empty round that has real rounds + * before it, it's kept as a single synthetic TBD placeholder match, giving + * every earlier match exactly one place to connect into. A *leading* empty + * round (nothing before it yet) is dropped — there's nothing that needs to + * point into it. + */ +const effectiveRounds = (model: BracketModel): BracketRound[] => { + const result: BracketRound[] = []; + let seenNonEmptyRound = false; + + for (const round of model.rounds) { + if (round.matches.length > 0) { + seenNonEmptyRound = true; + result.push(round); + } else if (seenNonEmptyRound) { + result.push({ ...round, matches: [placeholderRawMatch(round)] }); + } + } + + return result; +}; + +/** + * Maps this app's `BracketModel` (rounds ordered by stage, plus the + * connector edges `buildBracket` was able to infer) into the flat + * `Match[]` shape `@g-loot/react-tournament-brackets`'s + * `SingleEliminationBracket` expects. `thirdPlace` is intentionally + * excluded — it renders as an unconnected side slot outside the library + * (see `PlayoffBracket.tsx`). See {@link effectiveRounds} for how rounds + * with no visible matches are handled. + */ +export function toLibraryMatches(model: BracketModel): PlayoffLibraryMatch[] { + const rounds = effectiveRounds(model); + const edgeTargetsBySource = new Map(model.edges.map(edge => [edge.fromMatchId, edge.toMatchId])); + + // Populated one round ahead of when it's read: while walking round N we + // already know each match's resolved nextMatchId, so a sibling pair (two + // round-N matches converging on the same round-N+1 match) can be checked + // for a bye right there — the library's own column-building (generateColumn + // in single-elim-bracket.js) preserves each pair's original array order + // (its match.name-based sort is a no-op tie since no name is set here, and + // a stable sort with an all-zero comparator keeps insertion order), so + // "first sibling in this round's array" reliably means "the library's + // previousTopMatch", same as `resolveNextMatchId`'s own index/2 fallback + // already assumes. + const hideConnectorByChildId = new Map(); + + const byRound = rounds.map((round, roundIndex) => { + const nextRound = rounds[roundIndex + 1]; + const roundLabel = translateStageType(round.stageType).toUpperCase(); + + const siblingsByChildId = new Map(); + round.matches.forEach((match, matchIndex) => { + const childId = resolveNextMatchId(matchIndex, edgeTargetsBySource.get(match.id), nextRound); + if (!childId) return; + const siblings = siblingsByChildId.get(childId) ?? []; + siblings.push(match); + siblingsByChildId.set(childId, siblings); + }); + siblingsByChildId.forEach((siblings, childId) => { + if (siblings.length !== 2) return; + const [topSibling, bottomSibling] = siblings; + const topIsBye = isBracketBye(topSibling); + const bottomIsBye = isBracketBye(bottomSibling); + // Deep enough into a bracket, a pairing can have BOTH sides decided + // by a bye (e.g. two teams that each drew a walkover into the same + // slot) — every octavos-round bye still needs its own dangling + // connector hidden, not just the mixed bye/real case. + if (!topIsBye && !bottomIsBye) return; + hideConnectorByChildId.set(childId, { top: topIsBye, bottom: bottomIsBye }); + }); + + // Every match stays in the array, bye or not: the library computes each + // box's Y position — and every connector line — purely from + // (rowIndex, columnIndex) assuming round N always has exactly twice + // round N+1's match count (see calculate-match-position.js / + // connectors.js). Dropping bye entries here shifts the surviving + // matches' indices and desyncs that geometry from the actual bracket + // shape — cards land at the wrong row, connectors point at the wrong + // pair. A bye's card is hidden at render time instead (see + // BracketMatchLibraryAdapter), which keeps every index — and therefore + // every position — exactly where the library expects it. + return round.matches.map((match, matchIndex) => { + const hide = hideConnectorByChildId.get(match.id); + return { + id: match.id, + nextMatchId: resolveNextMatchId(matchIndex, edgeTargetsBySource.get(match.id), nextRound), + tournamentRoundText: roundLabel, + startTime: match.matchDate, + state: matchState(match), + participants: [toParticipant(match, 'home'), toParticipant(match, 'visitor')], + raw: match, + legs: round.legsByMatchId?.get(match.id), + hideTopConnector: hide?.top ?? false, + hideBottomConnector: hide?.bottom ?? false, + }; + }); + }); + + return byRound.flat(); +} + +/** Round header labels in column order, for the library's `roundTextGenerator`. */ +export function libraryRoundLabels(model: BracketModel): string[] { + return effectiveRounds(model).map(round => translateStageType(round.stageType).toUpperCase()); +} diff --git a/Club12-WebClient/src/modules/playoff/bracketMatchNavigation.test.ts b/Club12-WebClient/src/modules/playoff/bracketMatchNavigation.test.ts new file mode 100644 index 0000000..2b6bdfc --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/bracketMatchNavigation.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { IMatchSeriesResponse, ISeriesGameResponse } from '@/modules/matchSeries/type/matchSeries.d'; +import { resolveClickTargetMatchId } from './bracketMatchNavigation'; + +const guid = (seed: string): GUID => `${seed}-0000-0000-0000-000000000000` as GUID; + +const baseMatch = (overrides: Partial = {}): IMatchResponse => ({ + id: guid('match'), + matchDate: '2026-01-01T18:00:00Z', + matchType: 'Playoff' as IMatchResponse['matchType'], + slug: '', + homeTeam: { id: guid('home'), name: 'Home', logoUrl: '', score: 0, players: [], scorers: [] }, + visitorTeam: { id: guid('visitor'), name: 'Visitor', logoUrl: '', score: 0, players: [], scorers: [] }, + isFinished: false, + winningTeamId: null, + winningTeamName: null, + venue: null, + stageId: guid('stage'), + ...overrides, +}); + +const makeGame = (overrides: Partial & { id: GUID }): ISeriesGameResponse => ({ + matchDate: '2026-01-01T18:00:00Z', + homeTeamName: 'Home', + visitorTeamName: 'Visitor', + homeScore: null, + visitorScore: null, + winningTeamName: null, + isFinished: false, + matchType: 'Playoff' as ISeriesGameResponse['matchType'], + gameNumber: 1, + ...overrides, +}); + +const baseSeries = (games: ISeriesGameResponse[]): IMatchSeriesResponse => ({ + id: guid('series'), + stageId: guid('stage'), + homeTeamId: guid('home'), + homeTeamName: 'Home', + visitorTeamId: guid('visitor'), + visitorTeamName: 'Visitor', + bestOf: 3, + winningTeamId: null, + winningTeamName: null, + games, +}); + +describe('resolveClickTargetMatchId', () => { + it('returns the plain match id when there is no series and no legs', () => { + const match = baseMatch(); + expect(resolveClickTargetMatchId(match, undefined, undefined)).toBe(match.id); + }); + + it('returns undefined when a side is still TBD, even for a plain match', () => { + const match = baseMatch({ homeTeam: null }); + expect(resolveClickTargetMatchId(match, undefined, undefined)).toBeUndefined(); + }); + + it('never returns the synthetic series id — resolves to the first unfinished game', () => { + const match = baseMatch({ id: guid('series') }); + const g1 = makeGame({ id: guid('g1'), gameNumber: 1, isFinished: true }); + const g2 = makeGame({ id: guid('g2'), gameNumber: 2, isFinished: false }); + const series = baseSeries([g1, g2]); + + const targetId = resolveClickTargetMatchId(match, series, undefined); + + expect(targetId).toBe(g2.id); + expect(targetId).not.toBe(match.id); + }); + + it('resolves to the last game once every game in the series is finished', () => { + const match = baseMatch({ id: guid('series') }); + const g1 = makeGame({ id: guid('g1'), gameNumber: 1, isFinished: true }); + const g2 = makeGame({ id: guid('g2'), gameNumber: 2, isFinished: true }); + const series = baseSeries([g1, g2]); + + expect(resolveClickTargetMatchId(match, series, undefined)).toBe(g2.id); + }); + + it('returns undefined for a series with no games yet (no admin UI to create one — see #35)', () => { + const match = baseMatch({ id: guid('series') }); + const series = baseSeries([]); + + expect(resolveClickTargetMatchId(match, series, undefined)).toBeUndefined(); + }); + + it('never returns the synthetic tie id — resolves to the first unfinished leg', () => { + const tieMatch = baseMatch({ id: 'tie:stage-1:home:visitor' as GUID }); + const leg1 = baseMatch({ id: guid('leg1'), isFinished: true }); + const leg2 = baseMatch({ id: guid('leg2'), isFinished: false }); + + const targetId = resolveClickTargetMatchId(tieMatch, undefined, [leg1, leg2]); + + expect(targetId).toBe(leg2.id); + expect(targetId).not.toBe(tieMatch.id); + }); + + it('resolves a finished tie to its last leg', () => { + const tieMatch = baseMatch({ id: 'tie:stage-1:home:visitor' as GUID }); + const leg1 = baseMatch({ id: guid('leg1'), isFinished: true }); + const leg2 = baseMatch({ id: guid('leg2'), isFinished: true }); + + expect(resolveClickTargetMatchId(tieMatch, undefined, [leg1, leg2])).toBe(leg2.id); + }); +}); diff --git a/Club12-WebClient/src/modules/playoff/bracketMatchNavigation.ts b/Club12-WebClient/src/modules/playoff/bracketMatchNavigation.ts new file mode 100644 index 0000000..142ade1 --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/bracketMatchNavigation.ts @@ -0,0 +1,42 @@ +import { GUID } from '@/modules/core/types/types'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { IMatchSeriesResponse } from '@/modules/matchSeries/type/matchSeries.d'; + +/** + * Resolves the real, navigable `Match` id a bracket card's click should open + * — never the card's own `raw.id`, which for a BestOf > 1 round is a + * synthetic `MatchSeries` id and for a historical tie (`buildBracket.ts`'s + * `buildTieMatch`) is a synthetic `tie:${stageId}:${pairKey}` string, neither + * of which is a real `Match` row. + * + * - Series node: the first unfinished game, or the last game if the series + * is already decided. Returns `undefined` if the series has no games yet + * (nothing to navigate to — series/game creation has no admin UI, #35). + * - Tie node: same first-unfinished/else-last rule over the individual legs. + * - Plain match: `raw.id` is already a real `Match` row. + * + * Also returns `undefined` while either side of the matchup is still TBD + * (a future round awaiting a previous winner) — there is nothing useful to + * edit on a match with no teams assigned yet. + */ +export const resolveClickTargetMatchId = ( + raw: IMatchResponse, + series: IMatchSeriesResponse | undefined, + legs: IMatchResponse[] | undefined +): GUID | undefined => { + if (!raw.homeTeam || !raw.visitorTeam) { + return undefined; + } + + if (series) { + const nextGame = series.games.find(game => !game.isFinished); + return (nextGame ?? series.games[series.games.length - 1])?.id; + } + + if (legs && legs.length > 1) { + const nextLeg = legs.find(leg => !leg.isFinished); + return (nextLeg ?? legs[legs.length - 1])?.id; + } + + return raw.id; +}; diff --git a/Club12-WebClient/src/modules/playoff/buildBracket.test.ts b/Club12-WebClient/src/modules/playoff/buildBracket.test.ts new file mode 100644 index 0000000..54c8e2d --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/buildBracket.test.ts @@ -0,0 +1,614 @@ +import { describe, expect, it } from 'vitest'; +import { + buildBracket, + buildBrackets, + countSeriesWins, + groupStagesByBracket, + seriesToRepresentativeMatch, +} from '@/modules/playoff/buildBracket'; +import { IStageResponse, StageType } from '@/modules/stage/type/stage'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { ITeamMatchResponse } from '@/modules/team/type/team.d'; +import { IMatchSeriesResponse, ISeriesGameResponse } from '@/modules/matchSeries/type/matchSeries.d'; +import { GUID } from '@/modules/core/types/types'; + +const guid = (seed: string): GUID => `${seed}-0000-0000-0000-000000000000` as GUID; + +const makeTeam = ( + overrides: Partial & { id: GUID; name: string } +): ITeamMatchResponse => ({ + logoUrl: '', + score: 0, + players: [], + scorers: [], + ...overrides, +}); + +const makeStage = (overrides: Partial & { id: GUID; stageType: StageType }): IStageResponse => ({ + name: overrides.stageType, + slug: '', + isActive: true, + isElimination: true, + startDate: '2026-01-01', + endDate: '2026-01-31', + divisionId: guid('division'), + order: 0, + bestOf: 1, + roundRobinLegs: 1, + ...overrides, +}); + +const makeMatch = (overrides: Partial & { id: GUID; stageId: GUID }): IMatchResponse => ({ + matchDate: '2026-01-01T18:00:00Z', + matchType: 'Regular' as IMatchResponse['matchType'], + slug: '', + homeTeam: null, + visitorTeam: null, + isFinished: false, + winningTeamId: null, + winningTeamName: null, + venue: null, + ...overrides, +}); + +const makeGame = ( + overrides: Partial & { id: GUID } +): ISeriesGameResponse => ({ + matchDate: '2026-01-01T18:00:00Z', + homeTeamName: 'Home', + visitorTeamName: 'Visitor', + homeScore: null, + visitorScore: null, + winningTeamName: null, + isFinished: false, + matchType: 'Playoff' as ISeriesGameResponse['matchType'], + gameNumber: 1, + ...overrides, +}); + +const makeSeries = ( + overrides: Partial & { id: GUID; stageId: GUID } +): IMatchSeriesResponse => ({ + homeTeamId: guid('home'), + homeTeamName: 'Home', + visitorTeamId: guid('visitor'), + visitorTeamName: 'Visitor', + bestOf: 3, + winningTeamId: null, + winningTeamName: null, + games: [], + ...overrides, +}); + +describe('groupStagesByBracket', () => { + it('groups stages with no BracketName into one default group', () => { + const stageA = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal }); + const stageB = makeStage({ id: guid('final'), stageType: StageType.Final }); + + const groups = groupStagesByBracket([stageA, stageB]); + + expect(groups.size).toBe(1); + expect([...groups.values()][0]).toEqual([stageA, stageB]); + }); + + it('separates stages by their admin-defined BracketName', () => { + const goldStage = makeStage({ id: guid('gold-sf'), stageType: StageType.SemiFinal, bracketName: 'Copa de Oro' }); + const silverStage = makeStage({ id: guid('silver-sf'), stageType: StageType.SemiFinal, bracketName: 'Copa de Plata' }); + + const groups = groupStagesByBracket([goldStage, silverStage]); + + expect(groups.size).toBe(2); + expect(groups.get('Copa de Oro')).toEqual([goldStage]); + expect(groups.get('Copa de Plata')).toEqual([silverStage]); + }); +}); + +describe('countSeriesWins', () => { + it('tallies finished games by matching winner name to home/visitor', () => { + const series = makeSeries({ + id: guid('series'), + stageId: guid('sf'), + homeTeamName: 'Home', + visitorTeamName: 'Visitor', + games: [ + makeGame({ id: guid('g1'), isFinished: true, winningTeamName: 'Home', gameNumber: 1 }), + makeGame({ id: guid('g2'), isFinished: true, winningTeamName: 'Visitor', gameNumber: 2 }), + makeGame({ id: guid('g3'), isFinished: false, gameNumber: 3 }), + ], + }); + + expect(countSeriesWins(series)).toEqual({ home: 1, visitor: 1 }); + }); +}); + +describe('seriesToRepresentativeMatch', () => { + it('shows the game tally as the score once at least one game is finished, without marking a winner mid-series', () => { + const series = makeSeries({ + id: guid('series'), + stageId: guid('sf'), + games: [makeGame({ id: guid('g1'), isFinished: true, winningTeamName: 'Home', gameNumber: 1 })], + }); + + const representative = seriesToRepresentativeMatch(series); + + expect(representative.isFinished).toBe(true); + expect(representative.homeTeam?.score).toBe(1); + expect(representative.visitorTeam?.score).toBe(0); + expect(representative.winningTeamId).toBeNull(); + }); + + it('sets the winner once the series has been decided', () => { + const winnerId = guid('home'); + const series = makeSeries({ + id: guid('series'), + stageId: guid('sf'), + homeTeamId: winnerId, + winningTeamId: winnerId, + winningTeamName: 'Home', + games: [ + makeGame({ id: guid('g1'), isFinished: true, winningTeamName: 'Home', gameNumber: 1 }), + makeGame({ id: guid('g2'), isFinished: true, winningTeamName: 'Home', gameNumber: 2 }), + ], + }); + + const representative = seriesToRepresentativeMatch(series); + + expect(representative.winningTeamId).toBe(winnerId); + expect(representative.homeTeam?.score).toBe(2); + }); +}); + +describe('buildBrackets — multi-bracket + series grouping', () => { + it('builds one BracketModel per BracketName group', () => { + const goldStage = makeStage({ id: guid('gold-final'), stageType: StageType.Final, bracketName: 'Copa de Oro' }); + const silverStage = makeStage({ id: guid('silver-final'), stageType: StageType.Final, bracketName: 'Copa de Plata' }); + const goldMatch = makeMatch({ id: guid('m-gold'), stageId: goldStage.id }); + const silverMatch = makeMatch({ id: guid('m-silver'), stageId: silverStage.id }); + + const groups = buildBrackets([goldStage, silverStage], [goldMatch, silverMatch]); + + expect(groups).toHaveLength(2); + expect(groups.map(g => g.bracketName).sort()).toEqual(['Copa de Oro', 'Copa de Plata']); + expect(groups.find(g => g.bracketName === 'Copa de Oro')?.model.rounds[0].matches).toEqual([goldMatch]); + }); + + it('returns a single group with bracketName null when no stage carries a BracketName', () => { + const finalStage = makeStage({ id: guid('final'), stageType: StageType.Final }); + + const groups = buildBrackets([finalStage], []); + + expect(groups).toHaveLength(1); + expect(groups[0].bracketName).toBeNull(); + }); + + // Playoff draw & seeding: the public bracket view reads the draw date from + // the FIRST-ROUND stage's drawnAt, surfaced on the group so the view never + // needs to read the admin-only audit trail. + it('exposes the first-round stage drawnAt on the group', () => { + const sfStage = makeStage({ + id: guid('sf'), + stageType: StageType.SemiFinal, + order: 1, + drawnAt: '2026-05-01T12:00:00Z', + }); + const finalStage = makeStage({ id: guid('final'), stageType: StageType.Final, order: 2 }); + + const groups = buildBrackets([sfStage, finalStage], []); + + expect(groups[0].drawnAt).toBe('2026-05-01T12:00:00Z'); + }); + + it('is null when the first-round stage has not been drawn', () => { + const finalStage = makeStage({ id: guid('final'), stageType: StageType.Final }); + + const groups = buildBrackets([finalStage], []); + + expect(groups[0].drawnAt).toBeNull(); + }); + + it('renders a BestOf>1 round as one node per series instead of one per game', () => { + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal, bestOf: 3 }); + const series = makeSeries({ + id: guid('series'), + stageId: sfStage.id, + games: [ + makeGame({ id: guid('g1'), isFinished: true, winningTeamName: 'Home', gameNumber: 1 }), + makeGame({ id: guid('g2'), isFinished: true, winningTeamName: 'Home', gameNumber: 2 }), + ], + }); + const seriesByStageId = new Map([[sfStage.id, [series]]]); + + const groups = buildBrackets([sfStage], [], seriesByStageId); + + const sfRound = groups[0].model.rounds[0]; + expect(sfRound.matches).toHaveLength(1); + expect(sfRound.matches[0].id).toBe(series.id); + expect(sfRound.matches[0].homeTeam?.score).toBe(2); + }); +}); + +describe('buildBracket — round ordering and grouping', () => { + it('orders rounds Cuartos -> Semifinal -> Final regardless of input order, and groups matches by stageId', () => { + const qfStage = makeStage({ id: guid('qf'), stageType: StageType.QuarterFinal, order: 1 }); + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal, order: 2 }); + const finalStage = makeStage({ id: guid('final'), stageType: StageType.Final, order: 3 }); + const groupStage = makeStage({ id: guid('group'), stageType: StageType.Group, isElimination: false, order: 0 }); + + const unsortedInputStages = [finalStage, groupStage, qfStage, sfStage]; + + const qfMatch = makeMatch({ id: guid('m-qf'), stageId: qfStage.id }); + const sfMatch = makeMatch({ id: guid('m-sf'), stageId: sfStage.id }); + const finalMatch = makeMatch({ id: guid('m-final'), stageId: finalStage.id }); + const groupMatch = makeMatch({ id: guid('m-group'), stageId: groupStage.id }); + + const model = buildBracket(unsortedInputStages, [qfMatch, sfMatch, finalMatch, groupMatch]); + + expect(model.rounds.map(round => round.stageType)).toEqual([ + StageType.QuarterFinal, + StageType.SemiFinal, + StageType.Final, + ]); + expect(model.rounds[0].matches).toEqual([qfMatch]); + expect(model.rounds[1].matches).toEqual([sfMatch]); + expect(model.rounds[2].matches).toEqual([finalMatch]); + }); + + it('drops Group stages entirely and holds ThirdPlace aside from the main rounds', () => { + const finalStage = makeStage({ id: guid('final'), stageType: StageType.Final, order: 3 }); + const thirdPlaceStage = makeStage({ id: guid('third'), stageType: StageType.ThirdPlace, order: 3 }); + const groupStage = makeStage({ id: guid('group'), stageType: StageType.Group, isElimination: false, order: 0 }); + + const finalMatch = makeMatch({ id: guid('m-final'), stageId: finalStage.id }); + const thirdMatch = makeMatch({ id: guid('m-third'), stageId: thirdPlaceStage.id }); + const groupMatch = makeMatch({ id: guid('m-group'), stageId: groupStage.id }); + + const model = buildBracket([finalStage, thirdPlaceStage, groupStage], [finalMatch, thirdMatch, groupMatch]); + + expect(model.rounds).toHaveLength(1); + expect(model.rounds[0].stageType).toBe(StageType.Final); + expect(model.thirdPlace?.stageType).toBe(StageType.ThirdPlace); + expect(model.thirdPlace?.matches).toEqual([thirdMatch]); + }); +}); + +describe('buildBracket — TBD slots for unresolved participants', () => { + it('preserves a null homeTeam/visitorTeam on an unseeded Final match so the view can render TBD', () => { + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal, order: 1 }); + const finalStage = makeStage({ id: guid('final'), stageType: StageType.Final, order: 2 }); + + const sfMatch = makeMatch({ + id: guid('m-sf'), + stageId: sfStage.id, + winningTeamId: null, + }); + const unseededFinalMatch = makeMatch({ + id: guid('m-final'), + stageId: finalStage.id, + homeTeam: null, + visitorTeam: null, + }); + + const model = buildBracket([sfStage, finalStage], [sfMatch, unseededFinalMatch]); + + const finalRound = model.rounds.find(round => round.stageType === StageType.Final); + expect(finalRound?.matches[0].homeTeam).toBeNull(); + expect(finalRound?.matches[0].visitorTeam).toBeNull(); + }); + + it('still includes a round with an empty matches array when its stage has no match rows yet', () => { + const qfStage = makeStage({ id: guid('qf'), stageType: StageType.QuarterFinal, order: 1 }); + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal, order: 2 }); + + const model = buildBracket([qfStage, sfStage], []); + + expect(model.rounds).toHaveLength(2); + expect(model.rounds.find(round => round.stageType === StageType.SemiFinal)?.matches).toEqual( + [] + ); + }); +}); + +describe('buildBracket — client-side connector inference', () => { + it('emits one edge when a Cuartos winner appears in exactly one Semifinal match', () => { + const qfStage = makeStage({ id: guid('qf'), stageType: StageType.QuarterFinal, order: 1 }); + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal, order: 2 }); + const winnerTeamId = guid('team-a'); + + const qfMatch = makeMatch({ + id: guid('m-qf'), + stageId: qfStage.id, + winningTeamId: winnerTeamId, + homeTeam: makeTeam({ id: winnerTeamId, name: 'A', score: 3 }), + visitorTeam: makeTeam({ id: guid('team-b'), name: 'B', score: 1 }), + }); + const sfMatch = makeMatch({ + id: guid('m-sf'), + stageId: sfStage.id, + homeTeam: makeTeam({ id: winnerTeamId, name: 'A' }), + visitorTeam: null, + }); + + const model = buildBracket([qfStage, sfStage], [qfMatch, sfMatch]); + + expect(model.edges).toEqual([{ fromMatchId: qfMatch.id, toMatchId: sfMatch.id }]); + }); +}); + +describe('buildBracket — graceful degradation on ambiguous inference', () => { + const qfStage = makeStage({ id: guid('qf'), stageType: StageType.QuarterFinal, order: 1 }); + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal, order: 2 }); + + it('emits no edge when the source match has no winningTeamId yet (unplayed)', () => { + const qfMatch = makeMatch({ id: guid('m-qf'), stageId: qfStage.id, winningTeamId: null }); + const sfMatch = makeMatch({ + id: guid('m-sf'), + stageId: sfStage.id, + homeTeam: makeTeam({ id: guid('team-a'), name: 'A' }), + visitorTeam: null, + }); + + const model = buildBracket([qfStage, sfStage], [qfMatch, sfMatch]); + + expect(model.edges).toEqual([]); + expect(model.rounds).toHaveLength(2); + }); + + it('emits no edge when the winner matches zero next-round slots (not yet seeded)', () => { + const winnerTeamId = guid('team-a'); + const qfMatch = makeMatch({ id: guid('m-qf'), stageId: qfStage.id, winningTeamId: winnerTeamId }); + const sfMatch = makeMatch({ + id: guid('m-sf'), + stageId: sfStage.id, + homeTeam: null, + visitorTeam: null, + }); + + const model = buildBracket([qfStage, sfStage], [qfMatch, sfMatch]); + + expect(model.edges).toEqual([]); + }); + + it('emits no edge when the winner matches more than one next-round slot (data tie)', () => { + const winnerTeamId = guid('team-a'); + const qfMatch = makeMatch({ id: guid('m-qf'), stageId: qfStage.id, winningTeamId: winnerTeamId }); + const sfMatchOne = makeMatch({ + id: guid('m-sf1'), + stageId: sfStage.id, + homeTeam: makeTeam({ id: winnerTeamId, name: 'A' }), + visitorTeam: null, + }); + const sfMatchTwo = makeMatch({ + id: guid('m-sf2'), + stageId: sfStage.id, + homeTeam: makeTeam({ id: winnerTeamId, name: 'A' }), + visitorTeam: null, + }); + + const model = buildBracket([qfStage, sfStage], [qfMatch, sfMatchOne, sfMatchTwo]); + + expect(model.edges).toEqual([]); + }); + + it('emits no edge when the next round has no matches at all', () => { + const winnerTeamId = guid('team-a'); + const qfMatch = makeMatch({ id: guid('m-qf'), stageId: qfStage.id, winningTeamId: winnerTeamId }); + + const model = buildBracket([qfStage, sfStage], [qfMatch]); + + expect(model.edges).toEqual([]); + expect(model.rounds.find(round => round.stageType === StageType.SemiFinal)?.matches).toEqual( + [] + ); + }); +}); + +describe('buildBracket — raw-match tie grouping (home-and-away legs with no MatchSeries)', () => { + it('collapses two raw matches between the same team pair into one aggregate tie node', () => { + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal }); + const teamA = guid('2k'); + const teamB = guid('nn'); + + const leg1 = makeMatch({ + id: guid('leg1'), + stageId: sfStage.id, + matchDate: '2026-06-28T18:00:00Z', + homeTeam: makeTeam({ id: teamA, name: '2K', score: 41 }), + visitorTeam: makeTeam({ id: teamB, name: 'NN', score: 64 }), + isFinished: true, + winningTeamId: teamB, + winningTeamName: 'NN', + }); + const leg2 = makeMatch({ + id: guid('leg2'), + stageId: sfStage.id, + matchDate: '2026-07-05T18:00:00Z', + homeTeam: makeTeam({ id: teamB, name: 'NN', score: 54 }), + visitorTeam: makeTeam({ id: teamA, name: '2K', score: 57 }), + isFinished: true, + winningTeamId: teamA, + winningTeamName: '2K', + }); + + const model = buildBracket([sfStage], [leg1, leg2]); + const sfRound = model.rounds[0]; + + // One node per pairing, not one per raw Match row. + expect(sfRound.matches).toHaveLength(1); + + const tie = sfRound.matches[0]; + // Aggregate score summed by team id, not by home/visitor slot (legs swap sides). + expect(tie.homeTeam?.id).toBe(teamA); + expect(tie.homeTeam?.score).toBe(41 + 57); + expect(tie.visitorTeam?.id).toBe(teamB); + expect(tie.visitorTeam?.score).toBe(64 + 54); + expect(tie.isFinished).toBe(true); + // Aggregate: 2K 41+57=98, NN 64+54=118 — NN wins on aggregate despite + // 2K winning the second leg outright. + expect(tie.winningTeamId).toBe(teamB); + + // Legs are recorded for the view's per-leg breakdown, in chronological order. + expect(sfRound.legsByMatchId?.get(tie.id)).toEqual([leg1, leg2]); + }); + + it('does not collapse two matches between different team pairs in the same stage', () => { + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal }); + const matchOne = makeMatch({ + id: guid('m1'), + stageId: sfStage.id, + homeTeam: makeTeam({ id: guid('a'), name: 'A' }), + visitorTeam: makeTeam({ id: guid('b'), name: 'B' }), + }); + const matchTwo = makeMatch({ + id: guid('m2'), + stageId: sfStage.id, + homeTeam: makeTeam({ id: guid('c'), name: 'C' }), + visitorTeam: makeTeam({ id: guid('d'), name: 'D' }), + }); + + const model = buildBracket([sfStage], [matchOne, matchTwo]); + + expect(model.rounds[0].matches).toEqual([matchOne, matchTwo]); + expect(model.rounds[0].legsByMatchId?.size).toBe(0); + }); + + it('leaves a single match per pairing unaffected (the normal, non-grouped case)', () => { + const finalStage = makeStage({ id: guid('final'), stageType: StageType.Final }); + const finalMatch = makeMatch({ + id: guid('m-final'), + stageId: finalStage.id, + homeTeam: makeTeam({ id: guid('a'), name: 'A' }), + visitorTeam: makeTeam({ id: guid('b'), name: 'B' }), + }); + + const model = buildBracket([finalStage], [finalMatch]); + + expect(model.rounds[0].matches).toEqual([finalMatch]); + expect(model.rounds[0].legsByMatchId?.get(finalMatch.id)).toBeUndefined(); + }); + + it('does not group TBD slots or byes together even when several share the same stage', () => { + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal }); + const tbdOne = makeMatch({ id: guid('tbd1'), stageId: sfStage.id, homeTeam: null, visitorTeam: null }); + const tbdTwo = makeMatch({ id: guid('tbd2'), stageId: sfStage.id, homeTeam: null, visitorTeam: null }); + + const model = buildBracket([sfStage], [tbdOne, tbdTwo]); + + expect(model.rounds[0].matches).toEqual([tbdOne, tbdTwo]); + }); + + it('does not require exactly two legs — groups however many rows share the pairing', () => { + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal }); + const teamA = guid('a'); + const teamB = guid('b'); + const legs = [ + makeMatch({ + id: guid('leg1'), + stageId: sfStage.id, + matchDate: '2026-01-01T18:00:00Z', + homeTeam: makeTeam({ id: teamA, name: 'A', score: 10 }), + visitorTeam: makeTeam({ id: teamB, name: 'B', score: 8 }), + isFinished: true, + }), + makeMatch({ + id: guid('leg2'), + stageId: sfStage.id, + matchDate: '2026-01-08T18:00:00Z', + homeTeam: makeTeam({ id: teamB, name: 'B', score: 12 }), + visitorTeam: makeTeam({ id: teamA, name: 'A', score: 9 }), + isFinished: true, + }), + makeMatch({ + id: guid('leg3'), + stageId: sfStage.id, + matchDate: '2026-01-15T18:00:00Z', + homeTeam: makeTeam({ id: teamA, name: 'A', score: 15 }), + visitorTeam: makeTeam({ id: teamB, name: 'B', score: 5 }), + isFinished: true, + }), + ]; + + const model = buildBracket([sfStage], legs); + const sfRound = model.rounds[0]; + + expect(sfRound.matches).toHaveLength(1); + expect(sfRound.legsByMatchId?.get(sfRound.matches[0].id)).toHaveLength(3); + // A: 10 + 9 + 15 = 34, B: 8 + 12 + 5 = 25 + expect(sfRound.matches[0].homeTeam?.score).toBe(34); + expect(sfRound.matches[0].winningTeamId).toBe(teamA); + }); + + it('does not mark the tie finished (or decided) while any leg is still pending', () => { + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal }); + const teamA = guid('a'); + const teamB = guid('b'); + const leg1 = makeMatch({ + id: guid('leg1'), + stageId: sfStage.id, + matchDate: '2026-01-01T18:00:00Z', + homeTeam: makeTeam({ id: teamA, name: 'A', score: 41 }), + visitorTeam: makeTeam({ id: teamB, name: 'B', score: 64 }), + isFinished: true, + winningTeamId: teamB, + winningTeamName: 'B', + }); + const leg2 = makeMatch({ + id: guid('leg2'), + stageId: sfStage.id, + matchDate: '2026-01-08T18:00:00Z', + homeTeam: makeTeam({ id: teamB, name: 'B' }), + visitorTeam: makeTeam({ id: teamA, name: 'A' }), + isFinished: false, + }); + + const model = buildBracket([sfStage], [leg1, leg2]); + const tie = model.rounds[0].matches[0]; + + expect(tie.isFinished).toBe(false); + expect(tie.winningTeamId).toBeNull(); + }); +}); + +describe('buildBracket — RoundOf16', () => { + it('includes a manually-created RoundOf16 stage as the round before Cuartos', () => { + const r16Stage = makeStage({ id: guid('r16'), stageType: StageType.RoundOf16, order: 1 }); + const qfStage = makeStage({ id: guid('qf'), stageType: StageType.QuarterFinal, order: 2 }); + const sfStage = makeStage({ id: guid('sf'), stageType: StageType.SemiFinal, order: 3 }); + const finalStage = makeStage({ id: guid('final'), stageType: StageType.Final, order: 4 }); + + const model = buildBracket( + [finalStage, sfStage, qfStage, r16Stage], + [] + ); + + expect(model.rounds.map(round => round.stageType)).toEqual([ + StageType.RoundOf16, + StageType.QuarterFinal, + StageType.SemiFinal, + StageType.Final, + ]); + }); +}); + +describe('buildBracket — no elimination stages for the division', () => { + it('returns an empty, valid BracketModel when the division has only a Group stage', () => { + const groupStage = makeStage({ + id: guid('group'), + stageType: StageType.Group, + isElimination: false, + order: 0, + }); + const groupMatch = makeMatch({ id: guid('m-group'), stageId: groupStage.id }); + + const model = buildBracket([groupStage], [groupMatch]); + + expect(model.rounds).toEqual([]); + expect(model.thirdPlace).toBeUndefined(); + expect(model.edges).toEqual([]); + }); + + it('returns an empty, valid BracketModel when there are no stages at all', () => { + const model = buildBracket([], []); + + expect(model).toEqual({ rounds: [], thirdPlace: undefined, edges: [] }); + }); +}); diff --git a/Club12-WebClient/src/modules/playoff/buildBracket.ts b/Club12-WebClient/src/modules/playoff/buildBracket.ts new file mode 100644 index 0000000..c139f39 --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/buildBracket.ts @@ -0,0 +1,316 @@ +import { GUID } from '@/modules/core/types/types'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { IStageResponse, StageType } from '@/modules/stage/type/stage'; +import { BracketEdge, BracketGroup, BracketModel, BracketRound } from '@/modules/playoff/type/bracket.d'; +import { IMatchSeriesResponse } from '@/modules/matchSeries/type/matchSeries.d'; +import { aggregateLegScores, aggregateTieWinner } from '@/modules/playoff/matchStatus'; + +/** + * Canonical bracket depth order for the "main path" stage types. ThirdPlace + * is intentionally excluded — it is rendered as a side slot, never as a + * column in the main advancement line. + */ +const ROUND_ORDER: Partial> = { + [StageType.RoundOf16]: 1, + [StageType.QuarterFinal]: 2, + [StageType.SemiFinal]: 3, + [StageType.Final]: 4, +}; + +const isMainRoundStage = (stage: IStageResponse): boolean => + stage.stageType in ROUND_ORDER; + +const sortMainStages = (stages: IStageResponse[]): IStageResponse[] => + [...stages].sort((a, b) => { + const orderA = ROUND_ORDER[a.stageType] ?? Number.MAX_SAFE_INTEGER; + const orderB = ROUND_ORDER[b.stageType] ?? Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + return a.order - b.order; + }); + +/** The first-round (min-depth) main stage's drawnAt, or null when undrawn/absent. */ +const firstRoundDrawnAt = (stages: IStageResponse[]): string | null => + sortMainStages(stages.filter(isMainRoundStage))[0]?.drawnAt ?? null; + +/** + * Groups a stage's raw matches by the (unordered) pair of team ids + * involved, so that legs of the same tie played by the same two teams — + * e.g. a home-and-away semifinal recorded as separate `Match` rows — + * share one key. A match missing either team id (a TBD slot or bye) gets + * a key unique to itself, so it's never merged with anything. + */ +const pairKey = (match: IMatchResponse): string => { + const homeId = match.homeTeam?.id; + const visitorId = match.visitorTeam?.id; + if (!homeId || !visitorId) return `single:${match.id}`; + return [homeId, visitorId].sort().join('|'); +}; + +/** + * Collapses every raw match sharing the same team pair into one + * aggregate `IMatchResponse`-shaped tie node: each side's score is the + * sum of its finished legs (see {@link aggregateLegScores}), and the tie + * counts as finished, with a winner, only once every leg has been played + * and the aggregate isn't level (see {@link aggregateTieWinner}). Team + * identity, logo, players and scorers are carried over from the first + * (chronologically earliest) leg, since only the score differs per leg. + */ +const buildTieMatch = (stageId: GUID, legs: IMatchResponse[]): IMatchResponse => { + const first = legs[0]; + const totals = aggregateLegScores(legs); + const winner = aggregateTieWinner(legs); + + const withAggregateScore = ( + team: IMatchResponse['homeTeam'] + ): IMatchResponse['homeTeam'] => (team ? { ...team, score: totals.get(team.id) ?? 0 } : team); + + return { + id: `tie:${stageId}:${pairKey(first)}`, + matchDate: first.matchDate, + matchType: first.matchType, + slug: '', + homeTeam: withAggregateScore(first.homeTeam), + visitorTeam: withAggregateScore(first.visitorTeam), + isFinished: legs.every(leg => leg.isFinished), + winningTeamId: winner?.winningTeamId ?? null, + winningTeamName: winner?.winningTeamName ?? null, + venue: null, + stageId, + }; +}; + +/** + * Groups a stage's matches by team pair (see {@link pairKey}). A pair + * with a single row passes through unchanged — the common case of one + * match per bracket slot. A pair with more than one row (multiple legs + * of the same tie) collapses into one aggregate tie node via + * {@link buildTieMatch}, with its legs recorded in the returned + * `legsByMatchId` map (chronologically ordered) so the view can render a + * per-leg breakdown alongside the aggregate score. + */ +const groupTiesByTeamPair = ( + stageId: GUID, + matches: IMatchResponse[] +): { matches: IMatchResponse[]; legsByMatchId: Map } => { + const groups = new Map(); + const order: string[] = []; + + for (const match of matches) { + const key = pairKey(match); + const group = groups.get(key); + if (group) group.push(match); + else { + groups.set(key, [match]); + order.push(key); + } + } + + const groupedMatches: IMatchResponse[] = []; + const legsByMatchId = new Map(); + + for (const key of order) { + const legs = groups.get(key)!; + if (legs.length === 1) { + groupedMatches.push(legs[0]); + continue; + } + + const orderedLegs = [...legs].sort((a, b) => a.matchDate.localeCompare(b.matchDate)); + const tie = buildTieMatch(stageId, orderedLegs); + groupedMatches.push(tie); + legsByMatchId.set(tie.id, orderedLegs); + } + + return { matches: groupedMatches, legsByMatchId }; +}; + +const buildRound = (stage: IStageResponse, matches: IMatchResponse[]): BracketRound => { + const stageMatches = matches.filter(match => match.stageId === stage.id); + const { matches: groupedMatches, legsByMatchId } = groupTiesByTeamPair(stage.id, stageMatches); + + return { + stageId: stage.id, + stageType: stage.stageType, + matches: groupedMatches, + legsByMatchId, + }; +}; + +const participantIds = (match: IMatchResponse): GUID[] => + [match.homeTeam?.id, match.visitorTeam?.id].filter((id): id is GUID => Boolean(id)); + +/** + * Infers connectors from a source round into the next round by matching + * winningTeamId to a participant of a match in the next round. Emits an + * edge ONLY when exactly one next-round match matches the winner — see + * "Ambiguity handling" in design.md. + */ +const buildEdgesForRoundPair = ( + sourceRound: BracketRound, + nextRound: BracketRound +): BracketEdge[] => { + const edges: BracketEdge[] = []; + + if (nextRound.matches.length === 0) return edges; + + for (const sourceMatch of sourceRound.matches) { + const winnerId = sourceMatch.winningTeamId; + if (!winnerId) continue; + + const targets = nextRound.matches.filter(nextMatch => + participantIds(nextMatch).includes(winnerId) + ); + + if (targets.length !== 1) continue; + + edges.push({ fromMatchId: sourceMatch.id, toMatchId: targets[0].id }); + } + + return edges; +}; + +export function buildBracket( + stages: IStageResponse[], + matches: IMatchResponse[] +): BracketModel { + const eliminationStages = stages.filter(stage => stage.isElimination); + + const mainStages = sortMainStages(eliminationStages.filter(isMainRoundStage)); + const thirdPlaceStage = eliminationStages.find( + stage => stage.stageType === StageType.ThirdPlace + ); + + const rounds = mainStages.map(stage => buildRound(stage, matches)); + const thirdPlace = thirdPlaceStage + ? buildRound(thirdPlaceStage, matches) + : undefined; + + const edges: BracketEdge[] = rounds + .slice(0, -1) + .flatMap((round, index) => buildEdgesForRoundPair(round, rounds[index + 1])); + + return { rounds, thirdPlace, edges }; +} + +/** + * Groups admin-defined free text used to key stages that carry no + * BracketName into the division's single/default bracket. + */ +const DEFAULT_BRACKET_KEY = '__default__'; + +/** + * Groups a division's stages by their (admin-defined, free-text) + * BracketName. Stages with no BracketName share one default group, so a + * division with a single elimination path still renders as one bracket. + */ +export function groupStagesByBracket( + stages: IStageResponse[] +): Map { + const groups = new Map(); + + for (const stage of stages) { + const key = stage.bracketName?.trim() || DEFAULT_BRACKET_KEY; + const group = groups.get(key) ?? []; + group.push(stage); + groups.set(key, group); + } + + return groups; +} + +/** + * Tallies how many games of a series each side has won so far, by + * matching each finished game's winner name against the series' + * home/visitor team names. + */ +export function countSeriesWins(series: IMatchSeriesResponse): { + home: number; + visitor: number; +} { + let home = 0; + let visitor = 0; + + for (const game of series.games) { + if (!game.isFinished) continue; + if (game.winningTeamName === series.homeTeamName) home += 1; + else if (game.winningTeamName === series.visitorTeamName) visitor += 1; + } + + return { home, visitor }; +} + +/** + * Collapses a best-of-N series into a single bracket node: an + * IMatchResponse-shaped object carrying the aggregate game-win tally as + * each side's "score", so the existing bracket rendering (built for one + * match per pairing) can display series without any changes. The series + * is only shown as "finished" (enabling the win highlight) once it has + * actually been decided — an in-progress 1-0 lead never falsely reads as + * a completed round. + */ +export function seriesToRepresentativeMatch(series: IMatchSeriesResponse): IMatchResponse { + const { home, visitor } = countSeriesWins(series); + + return { + id: series.id, + matchDate: series.games[0]?.matchDate ?? '', + matchType: (series.games[0]?.matchType ?? 'Playoff') as IMatchResponse['matchType'], + slug: '', + homeTeam: { + id: series.homeTeamId, + name: series.homeTeamName, + logoUrl: '', + score: home, + players: [], + scorers: [], + }, + visitorTeam: { + id: series.visitorTeamId, + name: series.visitorTeamName, + logoUrl: '', + score: visitor, + players: [], + scorers: [], + }, + isFinished: home + visitor > 0, + winningTeamId: series.winningTeamId, + winningTeamName: series.winningTeamName, + venue: null, + stageId: series.stageId, + }; +} + +/** + * Builds one BracketModel per named bracket in a division (grouped by + * BracketName — free text, admin-defined, optional). Stages with + * BestOf > 1 are rendered as one node per series (using + * `seriesByStageId`) rather than one node per individual game. + */ +export function buildBrackets( + stages: IStageResponse[], + matches: IMatchResponse[], + seriesByStageId: Map = new Map() +): BracketGroup[] { + const stageGroups = groupStagesByBracket(stages); + + return [...stageGroups.entries()].map(([key, groupStages]) => { + const seriesStageIds = new Set( + groupStages.filter(stage => stage.bestOf > 1).map(stage => stage.id) + ); + + const effectiveMatches = seriesStageIds.size === 0 + ? matches + : [ + ...matches.filter(match => !match.stageId || !seriesStageIds.has(match.stageId)), + ...[...seriesStageIds].flatMap(stageId => + (seriesByStageId.get(stageId) ?? []).map(seriesToRepresentativeMatch) + ), + ]; + + return { + bracketName: key === DEFAULT_BRACKET_KEY ? null : key, + model: buildBracket(groupStages, effectiveMatches), + drawnAt: firstRoundDrawnAt(groupStages), + }; + }); +} diff --git a/Club12-WebClient/src/modules/playoff/matchStatus.ts b/Club12-WebClient/src/modules/playoff/matchStatus.ts new file mode 100644 index 0000000..21d93a4 --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/matchStatus.ts @@ -0,0 +1,180 @@ +import { GUID } from '@/modules/core/types/types'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { IMatchSeriesResponse } from '@/modules/matchSeries/type/matchSeries.d'; + +/** + * Wraps a standalone (best-of-1) playoff match in the same + * `IMatchSeriesResponse` shape a real best-of-N series carries, so a + * fixture list can render every playoff round through one `SeriesCard` + * component instead of a bo1 round looking like a different, plainer UI + * (a bare `MatchRow`) next to every BOx round's rich series card. Returns + * `null` for a slot still missing a side (a TBD match awaiting a previous + * round's winner) — there's no real matchup yet to present as a series. + */ +export const singleMatchAsSeries = (match: IMatchResponse): IMatchSeriesResponse | null => { + if (!match.homeTeam || !match.visitorTeam) return null; + + return { + id: match.id, + // stageId is never read for display (SeriesCard doesn't use it) — the + // fallback only exists to satisfy the type when a match's own stageId + // is somehow null. + stageId: match.stageId ?? match.id, + homeTeamId: match.homeTeam.id, + homeTeamName: match.homeTeam.name, + visitorTeamId: match.visitorTeam.id, + visitorTeamName: match.visitorTeam.name, + bestOf: 1, + winningTeamId: match.winningTeamId, + winningTeamName: match.winningTeamName, + games: [ + { + id: match.id, + matchDate: match.matchDate, + homeTeamName: match.homeTeam.name, + visitorTeamName: match.visitorTeam.name, + homeScore: match.homeTeam.score, + visitorScore: match.visitorTeam.score, + winningTeamName: match.winningTeamName, + isFinished: match.isFinished, + matchType: match.matchType, + status: match.status, + round: match.round, + gameNumber: 1, + }, + ], + }; +}; + +/** + * A match is a walkover (bye) when it was seeded with only one side present + * and is already finished — the other side never had an opponent, as + * opposed to a not-yet-seeded slot still waiting on a previous round's + * winner. + */ +export const isBracketBye = (match: IMatchResponse): boolean => + match.isFinished && Boolean(match.homeTeam) !== Boolean(match.visitorTeam); + +/** + * Whether `teamId` is the recorded winner of `match`. Always false while + * the match is unfinished or undecided. + */ +export const isBracketMatchWinner = (match: IMatchResponse, teamId?: string | null): boolean => + Boolean(match.isFinished && match.winningTeamId && teamId && match.winningTeamId === teamId); + +/** + * A bracket slot still awaiting a previous round's winner on at least one + * side (as opposed to a bye, which is already decided). + */ +export const isBracketPending = (match: IMatchResponse): boolean => + !isBracketBye(match) && (!match.homeTeam || !match.visitorTeam); + +/** + * Renders a team's display label for a bracket slot: the team name once + * assigned, "BYE" once a walkover has been decided, or "A definir" (TBD) + * while still awaiting a previous round's winner. + */ +export const bracketTeamLabel = ( + team: IMatchResponse['homeTeam'], + match: IMatchResponse +): string => { + if (team) return team.name; + return isBracketBye(match) ? 'BYE' : 'A definir'; +}; + +/** A stable, unique participant id for a bracket slot with no team assigned yet. */ +export const unresolvedParticipantId = (match: IMatchResponse, side: 'home' | 'visitor'): string => + `${match.id}:${side}`; + +/** The participant id the bracket library should use for one side of a match. */ +export const bracketParticipantId = ( + match: IMatchResponse, + side: 'home' | 'visitor' +): string => { + const team = side === 'home' ? match.homeTeam : match.visitorTeam; + return team?.id ?? unresolvedParticipantId(match, side); +}; + +export type BracketSide = 'home' | 'visitor'; + +/** + * Sums each team's score across every leg of a multi-leg tie (e.g. a + * historical home-and-away semifinal recorded as separate `Match` rows + * rather than a `BestOf` `MatchSeries`), keyed by team id rather than by + * home/visitor slot — legs commonly swap which side is "home". Only + * finished legs contribute to either total. + */ +export const aggregateLegScores = (legs: IMatchResponse[]): Map => { + const totals = new Map(); + + for (const leg of legs) { + if (!leg.isFinished) continue; + for (const team of [leg.homeTeam, leg.visitorTeam]) { + if (!team) continue; + totals.set(team.id, (totals.get(team.id) ?? 0) + team.score); + } + } + + return totals; +}; + +/** + * Decides the winner of a multi-leg tie by aggregate score — extending + * {@link isBracketMatchWinner}'s single-match "isFinished && winningTeamId" + * rule to a tie of N legs instead of forking a parallel decision rule. + * Returns null while any leg is still unplayed, or if the aggregate is + * level (undecided even though every leg has been played). + */ +export const aggregateTieWinner = ( + legs: IMatchResponse[] +): { winningTeamId: GUID; winningTeamName: string } | null => { + if (legs.length === 0 || legs.some(leg => !leg.isFinished)) return null; + + const totals = aggregateLegScores(legs); + const [first, second] = [...totals.entries()]; + if (!first || !second || first[1] === second[1]) return null; + + const [winningTeamId] = first[1] > second[1] ? first : second; + const winningTeam = legs + .flatMap(leg => [leg.homeTeam, leg.visitorTeam]) + .find(team => team?.id === winningTeamId); + + return winningTeam ? { winningTeamId: winningTeam.id, winningTeamName: winningTeam.name } : null; +}; + +/** + * One team's score in each finished game of a best-of-N series, in game + * order — so a bracket card can show "Juego 1, Juego 2, …" results at a + * glance instead of only the aggregate. Games carry team NAMES rather than + * ids (see `ISeriesGameResponse`), and a series can swap which side is + * "home" from game to game, so each game is matched by name rather than by + * home/visitor slot. An unplayed game is skipped, not padded with a + * placeholder. + */ +export const seriesGameScores = ( + series: IMatchSeriesResponse, + teamName: string +): number[] => + [...series.games] + .filter(game => game.isFinished) + .sort((a, b) => a.gameNumber - b.gameNumber) + .map(game => { + if (game.homeTeamName === teamName) return game.homeScore ?? 0; + if (game.visitorTeamName === teamName) return game.visitorScore ?? 0; + return 0; + }); + +/** + * One team's score in each finished leg of a multi-leg tie (no `MatchSeries` + * behind it), in chronological order — the `legs` array's own order, same + * as {@link aggregateLegScores}. Legs are already keyed by team id, unlike + * series games, since legs carry the full `IMatchResponse` team objects. + */ +export const legGameScores = (legs: IMatchResponse[], teamId: GUID): number[] => + legs + .filter(leg => leg.isFinished) + .map(leg => { + if (leg.homeTeam?.id === teamId) return leg.homeTeam.score; + if (leg.visitorTeam?.id === teamId) return leg.visitorTeam.score; + return 0; + }); diff --git a/Club12-WebClient/src/modules/playoff/templateBracket.test.ts b/Club12-WebClient/src/modules/playoff/templateBracket.test.ts new file mode 100644 index 0000000..03a4941 --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/templateBracket.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { StageType } from '@/modules/stage/type/stage'; +import { CupConfig } from '@/views/tournament/wizard/types'; +import { buildTemplateBracketModel } from './templateBracket'; + +const cup = (overrides: Partial = {}): CupConfig => ({ + id: 'cup-1', + name: 'Copa Oro', + qualifiers: 4, + bestOfByStage: {}, + hasThirdPlace: true, + ...overrides, +}); + +describe('buildTemplateBracketModel', () => { + it('builds a semifinal + final shape for 4 qualifiers, with a third-place side slot', () => { + const model = buildTemplateBracketModel(cup()); + + expect(model.rounds.map(round => round.stageType)).toEqual([ + StageType.SemiFinal, + StageType.Final, + ]); + expect(model.rounds[0].matches).toHaveLength(2); + expect(model.rounds[1].matches).toHaveLength(1); + expect(model.thirdPlace).toBeDefined(); + expect(model.thirdPlace!.matches).toHaveLength(1); + expect(model.edges).toEqual([]); + }); + + it('omits the third-place slot when the cup does not play one', () => { + const model = buildTemplateBracketModel(cup({ hasThirdPlace: false })); + + expect(model.thirdPlace).toBeUndefined(); + }); + + it('builds a full quarterfinal-through-final shape for 8 qualifiers', () => { + const model = buildTemplateBracketModel(cup({ qualifiers: 8 })); + + expect(model.rounds.map(round => round.stageType)).toEqual([ + StageType.QuarterFinal, + StageType.SemiFinal, + StageType.Final, + ]); + expect(model.rounds[0].matches).toHaveLength(4); + expect(model.rounds[1].matches).toHaveLength(2); + expect(model.rounds[2].matches).toHaveLength(1); + }); + + it('builds a single final-only shape for 2 qualifiers', () => { + const model = buildTemplateBracketModel(cup({ qualifiers: 2, hasThirdPlace: true })); + + expect(model.rounds.map(round => round.stageType)).toEqual([StageType.Final]); + expect(model.rounds[0].matches).toHaveLength(1); + // A 2-team cup has no semifinal losers to seed a third-place decider from. + expect(model.thirdPlace).toBeUndefined(); + }); + + it('every placeholder match has no participants and a unique id', () => { + const model = buildTemplateBracketModel(cup({ qualifiers: 8 })); + const allMatches = model.rounds.flatMap(round => round.matches); + + allMatches.forEach(match => { + expect(match.homeTeam).toBeNull(); + expect(match.visitorTeam).toBeNull(); + expect(match.isFinished).toBe(false); + }); + + const ids = allMatches.map(match => match.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('scopes ids to the cup, so two cups never collide', () => { + const modelA = buildTemplateBracketModel(cup({ id: 'cup-a' })); + const modelB = buildTemplateBracketModel(cup({ id: 'cup-b' })); + + const idsA = new Set(modelA.rounds.flatMap(round => round.matches.map(match => match.id))); + const idsB = modelB.rounds.flatMap(round => round.matches.map(match => match.id)); + + idsB.forEach(id => expect(idsA.has(id)).toBe(false)); + }); +}); diff --git a/Club12-WebClient/src/modules/playoff/templateBracket.ts b/Club12-WebClient/src/modules/playoff/templateBracket.ts new file mode 100644 index 0000000..60f8a8d --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/templateBracket.ts @@ -0,0 +1,82 @@ +import { GUID } from '@/modules/core/types/types'; +import { MatchType } from '@/modules/core/enum/match/matchType'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { StageType } from '@/modules/stage/type/stage'; +import { BracketModel, BracketRound } from '@/modules/playoff/type/bracket.d'; +import { CupConfig, qualifiersToStageTypes } from '@/views/tournament/wizard/types'; + +/** + * How many match slots a bracket round has, counting backward from a + * single-match Final — mirrors {@link qualifiersToStageTypes}'s own depth + * buckets, since the two must never disagree about the bracket's shape. + */ +const MATCHES_IN_ROUND: Partial> = { + [StageType.Final]: 1, + [StageType.SemiFinal]: 2, + [StageType.QuarterFinal]: 4, + [StageType.RoundOf16]: 8, +}; + +/** + * An empty, unplayed placeholder match for a bracket preview — every field + * that would identify real participants or a result is null/false, so it + * renders as a plain TBD slot through the existing bracket components + * (the same shape `bracketAdapter.ts`'s own `placeholderRawMatch` uses for + * an unfilled round in a real, partially-drawn bracket). + */ +const placeholderMatch = (id: GUID, stageId: GUID): IMatchResponse => ({ + id, + matchDate: '', + matchType: MatchType.Playoff, + slug: '', + homeTeam: null, + visitorTeam: null, + isFinished: false, + winningTeamId: null, + winningTeamName: null, + venue: null, + stageId, +}); + +/** + * Builds a preview {@link BracketModel} for a cup that has not been drawn + * yet (or has no persisted matches at all) — every round the cup's + * qualifier count implies ({@link qualifiersToStageTypes}), populated with + * empty placeholder slots instead of real matches. Reuses the exact same + * rendering pipeline (`PlayoffBracket`, `toLibraryMatches`) a real bracket + * uses: with no `edges` to infer a connector from, that pipeline already + * falls back to positional pairing (round N's match `i` feeds round N+1's + * match `floor(i/2)`), which is exactly a template bracket's shape. + */ +export const buildTemplateBracketModel = (cup: CupConfig): BracketModel => { + const stageTypes = qualifiersToStageTypes(cup.qualifiers, cup.hasThirdPlace); + const mainStageTypes = stageTypes.filter(stageType => stageType !== StageType.ThirdPlace); + + const rounds: BracketRound[] = mainStageTypes.map(stageType => { + const stageId = `template-${cup.id}-${stageType}` as GUID; + const matchCount = MATCHES_IN_ROUND[stageType] ?? 1; + + return { + stageId, + stageType, + matches: Array.from({ length: matchCount }, (_, index) => + placeholderMatch(`${stageId}-${index}` as GUID, stageId) + ), + }; + }); + + const hasThirdPlace = stageTypes.includes(StageType.ThirdPlace); + const thirdPlaceStageId = `template-${cup.id}-${StageType.ThirdPlace}` as GUID; + + return { + rounds, + thirdPlace: hasThirdPlace + ? { + stageId: thirdPlaceStageId, + stageType: StageType.ThirdPlace, + matches: [placeholderMatch(`${thirdPlaceStageId}-0` as GUID, thirdPlaceStageId)], + } + : undefined, + edges: [], + }; +}; diff --git a/Club12-WebClient/src/modules/playoff/type/bracket.d.ts b/Club12-WebClient/src/modules/playoff/type/bracket.d.ts new file mode 100644 index 0000000..5f3e7f9 --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/type/bracket.d.ts @@ -0,0 +1,65 @@ +import { GUID } from '@/modules/core/types/types'; +import { IMatchResponse } from '@/modules/match/type/match.d'; +import { StageType } from '@/modules/stage/type/stage'; + +/** + * A single inferred connector between a source match and the next-round + * match its winner advances into. Only emitted when the mapping is + * unambiguous — see buildBracket's degradation rules. + */ +export interface BracketEdge { + fromMatchId: GUID; + toMatchId: GUID; +} + +/** + * One column of the bracket tree: all matches belonging to a single + * elimination stage (e.g. all Semifinal matches for a division). + */ +export interface BracketRound { + stageId: GUID; + stageType: StageType; + matches: IMatchResponse[]; + /** + * Maps a bracket node's id to its individual legs (chronologically + * ordered) when that node aggregates more than one raw `Match` row + * between the same two teams in this round — e.g. a historical + * home-and-away tie with no `MatchSeries` behind it (see + * `buildBracket.ts`'s tie grouping). Optional and absent/undefined for + * the common case of one match per bracket slot, so existing + * `BracketRound` literals (tests, callers) stay valid without it. + */ + legsByMatchId?: Map; +} + +/** + * The full client-side bracket model for a division, ready to render. + * `rounds` is ordered QuarterFinal -> SemiFinal -> Final. `thirdPlace` is a + * side slot rendered next to the Final, not chained into the main path. + * `edges` only contains unambiguous connectors; the model is always valid + * (never throws) even when no connectors can be inferred. + */ +export interface BracketModel { + rounds: BracketRound[]; + thirdPlace?: BracketRound; + edges: BracketEdge[]; +} + +/** + * One named parallel bracket within a division (e.g. an admin-defined + * "Copa de Oro" / "Copa de Plata" pair, or a single unnamed bracket when + * the division only has one elimination path). `bracketName` is free + * text, entirely admin-defined; null means the stage(s) carry no + * BracketName and form the division's single/default bracket. + */ +export interface BracketGroup { + bracketName: string | null; + model: BracketModel; + /** + * When this bracket's seeding draw was committed, read from the + * first-round (min-depth) main stage's `drawnAt`, or null when it has not + * been drawn yet (or was seeded from group standings, which does not set + * it). Drives the public "Sorteo realizado el [fecha]" caption. + */ + drawnAt: string | null; +} diff --git a/Club12-WebClient/src/modules/playoff/type/gLootBracketTypes.d.ts b/Club12-WebClient/src/modules/playoff/type/gLootBracketTypes.d.ts new file mode 100644 index 0000000..514a9d9 --- /dev/null +++ b/Club12-WebClient/src/modules/playoff/type/gLootBracketTypes.d.ts @@ -0,0 +1,17 @@ +/** + * Re-exports `@g-loot/react-tournament-brackets`'s TypeScript types. The + * package's public entry point only re-exports runtime values + * (components, `MATCH_STATES`, `createTheme`) — its type declarations + * live in `dist/esm/types.d.ts` and aren't re-exported from there, so + * every consumer in this app imports them from here instead of reaching + * into the package's internal path directly (see also + * `src/g-loot-react-tournament-brackets.d.ts` for the matching fix to the + * package's own broken `types` field). + */ +export type { + MatchType as LibraryMatch, + ParticipantType as LibraryParticipant, + MatchComponentProps as LibraryMatchComponentProps, + OptionsType as LibraryBracketOptions, + ThemeType as LibraryBracketTheme, +} from '@g-loot/react-tournament-brackets/dist/esm/types'; diff --git a/Club12-WebClient/src/modules/pointDeduction/hook/pointDeduction.hook.test.ts b/Club12-WebClient/src/modules/pointDeduction/hook/pointDeduction.hook.test.ts new file mode 100644 index 0000000..55c621a --- /dev/null +++ b/Club12-WebClient/src/modules/pointDeduction/hook/pointDeduction.hook.test.ts @@ -0,0 +1,101 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { usePointDeductions } from '@/modules/pointDeduction/hook/pointDeduction.hook'; +import { pointDeductionService } from '@/modules/pointDeduction/service/pointDeduction.service'; +import { IPointDeductionResponse } from '@/modules/pointDeduction/type/pointDeduction'; +import type { GUID } from '@/modules/core/types/types'; + +vi.mock('@/modules/pointDeduction/service/pointDeduction.service'); + +const mocked = vi.mocked(pointDeductionService); +const DIVISION_ID = 'division-1' as unknown as GUID; + +const buildDeduction = ( + overrides: Partial +): IPointDeductionResponse => ({ + id: 'deduction-1' as unknown as GUID, + divisionId: DIVISION_ID, + teamId: 'team-1' as unknown as GUID, + teamName: 'Aguará', + points: 2, + reason: 'Alineación indebida', + dateCreated: '2026-01-01T00:00:00Z', + ...overrides, +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const asAxios = (data: T) => ({ data }) as any; + +describe('usePointDeductions', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('loads the division deductions on mount', async () => { + mocked.getPointDeductionsByDivisionId.mockResolvedValue( + asAxios([buildDeduction({})]) + ); + + const { result } = renderHook(() => usePointDeductions(DIVISION_ID)); + + await waitFor(() => expect(result.current.deductions).toHaveLength(1)); + expect(mocked.getPointDeductionsByDivisionId).toHaveBeenCalledWith( + DIVISION_ID + ); + }); + + it('stays idle without a division id', async () => { + const { result } = renderHook(() => usePointDeductions(undefined)); + + await act(async () => { + await result.current.refresh(); + }); + + expect(mocked.getPointDeductionsByDivisionId).not.toHaveBeenCalled(); + expect(result.current.deductions).toEqual([]); + }); + + it('creates a deduction then refreshes the list', async () => { + mocked.getPointDeductionsByDivisionId + .mockResolvedValueOnce(asAxios([])) + .mockResolvedValueOnce(asAxios([buildDeduction({})])); + mocked.addPointDeduction.mockResolvedValue(asAxios(buildDeduction({}))); + + const { result } = renderHook(() => usePointDeductions(DIVISION_ID)); + await waitFor(() => + expect(mocked.getPointDeductionsByDivisionId).toHaveBeenCalledTimes(1) + ); + + await act(async () => { + await result.current.create({ + teamId: 'team-1' as unknown as GUID, + points: 2, + reason: 'Alineación indebida', + }); + }); + + expect(mocked.addPointDeduction).toHaveBeenCalledWith(DIVISION_ID, { + teamId: 'team-1', + points: 2, + reason: 'Alineación indebida', + }); + await waitFor(() => expect(result.current.deductions).toHaveLength(1)); + }); + + it('removes a deduction then refreshes the list', async () => { + mocked.getPointDeductionsByDivisionId + .mockResolvedValueOnce(asAxios([buildDeduction({})])) + .mockResolvedValueOnce(asAxios([])); + mocked.deletePointDeduction.mockResolvedValue(asAxios(undefined)); + + const { result } = renderHook(() => usePointDeductions(DIVISION_ID)); + await waitFor(() => expect(result.current.deductions).toHaveLength(1)); + + await act(async () => { + await result.current.remove('deduction-1' as unknown as GUID); + }); + + expect(mocked.deletePointDeduction).toHaveBeenCalledWith('deduction-1'); + await waitFor(() => expect(result.current.deductions).toHaveLength(0)); + }); +}); diff --git a/Club12-WebClient/src/modules/pointDeduction/hook/pointDeduction.hook.ts b/Club12-WebClient/src/modules/pointDeduction/hook/pointDeduction.hook.ts new file mode 100644 index 0000000..b2f4339 --- /dev/null +++ b/Club12-WebClient/src/modules/pointDeduction/hook/pointDeduction.hook.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from 'react'; +import { GUID } from '@/modules/core/types/types'; +import { pointDeductionService } from '@/modules/pointDeduction/service/pointDeduction.service'; +import { + IAddPointDeduction, + IPointDeductionResponse, +} from '@/modules/pointDeduction/type/pointDeduction'; + +/** + * The shape returned by {@link usePointDeductions}. + */ +export interface UsePointDeductions { + /** The division's deductions, newest first. */ + deductions: IPointDeductionResponse[]; + /** Whether a list refresh is in flight. */ + loading: boolean; + /** Reloads the division's deductions from the server. */ + refresh: () => Promise; + /** Applies a new deduction and refreshes the list. Returns the created row. */ + create: ( + deduction: IAddPointDeduction + ) => Promise; + /** Removes a deduction by id and refreshes the list. */ + remove: (id: GUID) => Promise; +} + +/** + * Manages the disciplinary point deductions (deducción de puntos) of a single + * division: loads the list, and creates/removes entries. Standalone (no + * provider needed) so it can be dropped into the division admin view. Pass a + * falsy `divisionId` to keep it idle until the division has resolved. + * @param divisionId - The division whose deductions to manage. + */ +export const usePointDeductions = ( + divisionId: GUID | undefined +): UsePointDeductions => { + const [deductions, setDeductions] = useState([]); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(async () => { + if (!divisionId) { + return; + } + setLoading(true); + try { + const response = + await pointDeductionService.getPointDeductionsByDivisionId(divisionId); + setDeductions(response.data ?? []); + } finally { + setLoading(false); + } + }, [divisionId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const create = useCallback( + async ( + deduction: IAddPointDeduction + ): Promise => { + if (!divisionId) { + throw new Error('A division is required to add a point deduction.'); + } + const response = await pointDeductionService.addPointDeduction( + divisionId, + deduction + ); + await refresh(); + return response.data; + }, + [divisionId, refresh] + ); + + const remove = useCallback( + async (id: GUID): Promise => { + await pointDeductionService.deletePointDeduction(id); + await refresh(); + }, + [refresh] + ); + + return { deductions, loading, refresh, create, remove }; +}; diff --git a/Club12-WebClient/src/modules/pointDeduction/service/pointDeduction.service.ts b/Club12-WebClient/src/modules/pointDeduction/service/pointDeduction.service.ts new file mode 100644 index 0000000..a10c3f1 --- /dev/null +++ b/Club12-WebClient/src/modules/pointDeduction/service/pointDeduction.service.ts @@ -0,0 +1,49 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { GUID } from '@/modules/core/types/types'; +import { sendDelete, sendGet, sendPost } from '@/modules/core/utils/axiosUtils'; +import { + IAddPointDeduction, + IPointDeductionResponse, +} from '@/modules/pointDeduction/type/pointDeduction'; + +/** + * Service for the disciplinary point-deduction (deducción de puntos) + * endpoints. Creating and deleting require AdminOrOwner; listing is public. + */ +export const pointDeductionService = { + /** + * Applies a point deduction to a team within a division. + * @param {GUID} divisionId - The division whose standings the penalty affects. + * @param {IAddPointDeduction} deduction - The team, points and reason. + * @returns {Promise>} The created deduction. + */ + addPointDeduction: async ( + divisionId: GUID, + deduction: IAddPointDeduction + ): Promise> => + sendPost( + `${routes.divisions}/${divisionId}/point-deductions`, + deduction + ), + + /** + * Lists every point deduction applied in a division (newest first). + * @param {GUID} divisionId - The division whose deductions to list. + * @returns {Promise>} The deductions. + */ + getPointDeductionsByDivisionId: async ( + divisionId: GUID + ): Promise> => + sendGet( + `${routes.divisions}/${divisionId}/point-deductions` + ), + + /** + * Removes a point deduction by its id. + * @param {GUID} id - The id of the deduction to remove. + * @returns {Promise>} The server response. + */ + deletePointDeduction: async (id: GUID): Promise> => + sendDelete(`${routes.pointDeductions}/${id}`), +}; diff --git a/Club12-WebClient/src/modules/pointDeduction/type/pointDeduction.d.ts b/Club12-WebClient/src/modules/pointDeduction/type/pointDeduction.d.ts new file mode 100644 index 0000000..d6dfdfd --- /dev/null +++ b/Club12-WebClient/src/modules/pointDeduction/type/pointDeduction.d.ts @@ -0,0 +1,46 @@ +import { GUID } from '@/modules/core/types/types'; + +/** + * A disciplinary point deduction (deducción de puntos) record, as returned by + * the create and list endpoints. Mirrors the backend `PointDeductionResponse`. + * @interface IPointDeductionResponse + */ +export interface IPointDeductionResponse { + /** The unique identifier of the deduction. */ + id: GUID; + + /** The division whose standings the deduction affects. */ + divisionId: GUID; + + /** The penalised team's id. */ + teamId: GUID; + + /** The penalised team's name, when the team was loaded. */ + teamName?: string; + + /** The table points subtracted from the team's total. */ + points: number; + + /** The disciplinary reason (motivo) for the deduction. */ + reason: string; + + /** When the deduction was applied (ISO date). */ + dateCreated: string; +} + +/** + * The request body for applying a point deduction to a team in a division. + * The division is taken from the route, not the body. Mirrors the backend + * `CreatePointDeductionRequest`. + * @interface IAddPointDeduction + */ +export interface IAddPointDeduction { + /** The team to penalise. */ + teamId: GUID; + + /** The positive amount of table points to subtract (at least 1). */ + points: number; + + /** The disciplinary reason (motivo), up to 300 characters. */ + reason: string; +} diff --git a/Club12-WebClient/src/modules/scorer/context/scorer.context.tsx b/Club12-WebClient/src/modules/scorer/context/scorer.context.tsx new file mode 100644 index 0000000..eb7fb04 --- /dev/null +++ b/Club12-WebClient/src/modules/scorer/context/scorer.context.tsx @@ -0,0 +1,102 @@ +import React, { + createContext, + ReactNode, + useCallback, + useMemo, + useState, +} from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { GenericResponsePagination } from '@/modules/core/types/types'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { scorerService } from '@/modules/scorer/service/scorer.service'; +import { + IScorerByPlayerResponse, + IScorerByTeamFiltered, + IScorerByTeamResponse, + IScorerContextProps, + IScorerFiltered, +} from '@/modules/scorer/type/scorer.d'; +import { scorerKeys } from '@/modules/scorer/queryKeys'; + +export const ScorerContext = createContext( + undefined +); + +export const ScorerProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [scorersByTeam, setScorersByTeam] = useState< + IScorerByTeamResponse[] | null + >(null); + const [scorersByPlayer, setScorersByPlayer] = useState< + IScorerByPlayerResponse[] | null + >(null); + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const getScorersByTeamFiltered = useCallback( + async ( + filter: IScorerByTeamFiltered + ): Promise | void> => { + try { + const response = await queryClient.fetchQuery({ + queryKey: scorerKeys.byTeam(filter), + queryFn: async () => + await scorerService.getScorersByTeamFiltered(filter), + }); + + if (response?.data?.items) { + setScorersByTeam(response.data.items); + return response.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError, queryClient] + ); + + const getScorersByPlayerFiltered = useCallback( + async ( + filter: IScorerFiltered + ): Promise | void> => { + try { + const response = await queryClient.fetchQuery({ + queryKey: scorerKeys.byPlayer(filter), + queryFn: async () => + await scorerService.getScorersByPlayerFiltered(filter), + }); + + if (response?.data?.items) { + setScorersByPlayer(response.data.items); + return response.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError, queryClient] + ); + + const container: IScorerContextProps = useMemo( + () => ({ + scorersByTeam, + scorersByPlayer, + getScorersByTeamFiltered, + getScorersByPlayerFiltered, + }), + [ + getScorersByPlayerFiltered, + getScorersByTeamFiltered, + scorersByPlayer, + scorersByTeam, + ] + ); + + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/scorer/hook/scorer.hook.ts b/Club12-WebClient/src/modules/scorer/hook/scorer.hook.ts new file mode 100644 index 0000000..e424e4b --- /dev/null +++ b/Club12-WebClient/src/modules/scorer/hook/scorer.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { ScorerContext } from '@/modules/scorer/context/scorer.context'; + +export const useScorer = () => { + const context = useContext(ScorerContext); + if (!context) { + throw new Error('useScorer must be used within a ScorerProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/scorer/queryKeys.test.ts b/Club12-WebClient/src/modules/scorer/queryKeys.test.ts new file mode 100644 index 0000000..562dd1f --- /dev/null +++ b/Club12-WebClient/src/modules/scorer/queryKeys.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { scorerKeys } from './queryKeys'; +import { IScorerByTeamFiltered, IScorerFiltered } from '@/modules/scorer/type/scorer.d'; + +describe('scorerKeys', () => { + it('byTeam(filter) returns the byTeam literal', () => { + const filter: IScorerByTeamFiltered = { pageNumber: 1 }; + expect(scorerKeys.byTeam(filter)).toEqual(['scorer', 'byTeam', filter]); + }); + + it('byPlayer(filter) returns the byPlayer literal', () => { + const filter: IScorerFiltered = { pageNumber: 1 }; + expect(scorerKeys.byPlayer(filter)).toEqual([ + 'scorer', + 'byPlayer', + filter, + ]); + }); +}); diff --git a/Club12-WebClient/src/modules/scorer/queryKeys.ts b/Club12-WebClient/src/modules/scorer/queryKeys.ts new file mode 100644 index 0000000..eb9b228 --- /dev/null +++ b/Club12-WebClient/src/modules/scorer/queryKeys.ts @@ -0,0 +1,8 @@ +import { IScorerByTeamFiltered, IScorerFiltered } from '@/modules/scorer/type/scorer.d'; + +export const scorerKeys = { + byTeam: (filter: IScorerByTeamFiltered) => + ['scorer', 'byTeam', filter] as const, + byPlayer: (filter: IScorerFiltered) => + ['scorer', 'byPlayer', filter] as const, +}; diff --git a/Club12-WebClient/src/modules/scorer/service/scorer.service.ts b/Club12-WebClient/src/modules/scorer/service/scorer.service.ts new file mode 100644 index 0000000..b69265b --- /dev/null +++ b/Club12-WebClient/src/modules/scorer/service/scorer.service.ts @@ -0,0 +1,31 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { MatchType } from '@/modules/core/enum/match/matchType'; +import { GenericResponsePagination } from '@/modules/core/types/types'; +import { sendGet } from '@/modules/core/utils/axiosUtils'; +import { + IScorerByPlayerResponse, + IScorerByTeamFiltered, + IScorerByTeamResponse, + IScorerFiltered, +} from '@/modules/scorer/type/scorer.d'; + +export const scorerService = { + getScorersByTeamFiltered: async ( + filter: IScorerByTeamFiltered + ): Promise>> => + await sendGet( + `${routes.scorer}/by-team`, + withTablePageSize({ + ...filter, + type: MatchType.Regular, + }) + ), + + getScorersByPlayerFiltered: async ( + filter: IScorerFiltered + ): Promise< + AxiosResponse> + > => await sendGet(`${routes.scorer}/by-player`, withTablePageSize(filter)), +}; diff --git a/Club12-WebClient/src/modules/scorer/type/scorer.d.ts b/Club12-WebClient/src/modules/scorer/type/scorer.d.ts new file mode 100644 index 0000000..c329b63 --- /dev/null +++ b/Club12-WebClient/src/modules/scorer/type/scorer.d.ts @@ -0,0 +1,77 @@ +import { + Filtered, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { MatchFiltered } from '@/modules/match/type/match'; + +export interface IScorerContextProps { + scorersByTeam: IScorerByTeamResponse[] | null; + scorersByPlayer: IScorerByPlayerResponse[] | null; + + getScorersByTeamFiltered( + filter: IScorerByTeamFiltered + ): Promise | void>; + + getScorersByPlayerFiltered( + filter: IScorerFiltered + ): Promise | void>; +} + +export interface IScorerFiltered extends Filtered { + tournamentId?: GUID; + /** Scopes the ranking to one division (a zone or the cross-division cup) — every stage in it. */ + divisionId?: GUID; + /** Scopes the ranking to a single stage (e.g. just the group phase, or one named playoff bracket's round). */ + stageId?: GUID; + matchId?: GUID; + teamId?: GUID; + playerId?: GUID; + /** + * Scopes the goleadores ranking to a whole SEASON (HU-85) — the calendar year + * of a tournament's start date. Independent from `tournamentId`. Leaving both + * `tournamentId` and `season` unset yields the ALL-TIME ranking. + */ + season?: number; +} + +/** + * The three HU-85 ranking scopes a goleadores view can switch between: + * a single tournament, a whole season (calendar year), or all-time. + */ +export type ScorerScope = 'tournament' | 'season' | 'allTime'; + +export type IScorerByTeamFiltered = MatchFiltered; + +export interface IScorerBaseResponse { + points: number; +} + +export interface IScorerByPlayerResponse extends IScorerBaseResponse { + playerId: GUID; + fullName: string; + /** The player's jersey number (dorsal), when known — for the match kit. */ + jerseyNumber?: number | null; + /** + * The player's current team. Present on the goleadores ranking; omitted + * from a match's own per-team `scorers` list (redundant there — the + * team is already implied by which side of the match the list came from). + */ + teamId?: GUID; + /** The player's current team's name. See {@link teamId}. */ + teamName?: string; + /** The player's current team's crest, for the ranking row. */ + teamLogoUrl?: string; + /** The player's current team's kit — for rendering the jersey alongside the dorsal. */ + teamShirtColor?: string; + teamJerseyStyle?: string; + teamShirtSecondaryColor?: string | null; + teamShirtTertiaryColor?: string | null; +} + +export interface IScorerByTeamResponse extends IScorerBaseResponse { + teamId: GUID; + name: string; +} + +export type ScorersViewMode = 'team' | 'player'; diff --git a/Club12-WebClient/src/modules/scorer/utils/scorerScope.test.ts b/Club12-WebClient/src/modules/scorer/utils/scorerScope.test.ts new file mode 100644 index 0000000..1af64be --- /dev/null +++ b/Club12-WebClient/src/modules/scorer/utils/scorerScope.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { buildScorerScopeParams } from '@/modules/scorer/utils/scorerScope'; + +const tournamentId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' as GUID; +const divisionId = '11111111-2222-3333-4444-555555555555' as GUID; +const stageId = '66666666-7777-8888-9999-000000000000' as GUID; + +describe('buildScorerScopeParams', () => { + it('tournament scope sends tournamentId (+ division/stage) and no season', () => { + const params = buildScorerScopeParams('tournament', { + tournamentId, + divisionId, + stageId, + season: 2026, + }); + + expect(params).toEqual({ tournamentId, divisionId, stageId }); + expect(params.season).toBeUndefined(); + }); + + it('tournament scope drops empty division/stage to undefined', () => { + const params = buildScorerScopeParams('tournament', { + tournamentId, + divisionId: '', + stageId: '', + }); + + expect(params).toEqual({ + tournamentId, + divisionId: undefined, + stageId: undefined, + }); + }); + + it('season scope sends only the season year, no tournamentId', () => { + const params = buildScorerScopeParams('season', { + tournamentId, + season: 2026, + }); + + expect(params).toEqual({ season: 2026 }); + expect(params.tournamentId).toBeUndefined(); + }); + + it('season scope with no year selected sends an undefined season', () => { + const params = buildScorerScopeParams('season', { season: '' }); + + expect(params).toEqual({ season: undefined }); + }); + + it('allTime scope sends neither tournamentId nor season', () => { + const params = buildScorerScopeParams('allTime', { + tournamentId, + season: 2026, + }); + + expect(params).toEqual({}); + }); +}); diff --git a/Club12-WebClient/src/modules/scorer/utils/scorerScope.ts b/Club12-WebClient/src/modules/scorer/utils/scorerScope.ts new file mode 100644 index 0000000..022ce66 --- /dev/null +++ b/Club12-WebClient/src/modules/scorer/utils/scorerScope.ts @@ -0,0 +1,53 @@ +import { GUID } from '@/modules/core/types/types'; +import { IScorerFiltered, ScorerScope } from '@/modules/scorer/type/scorer.d'; + +/** + * Fields a HU-85 scope selection contributes to the goleadores query. The + * remaining `IScorerFiltered` fields (pagination, etc.) are merged in by the + * caller. + */ +export type ScorerScopeParams = Pick< + IScorerFiltered, + 'tournamentId' | 'divisionId' | 'stageId' | 'season' +>; + +export interface ScorerScopeInput { + tournamentId?: GUID | ''; + divisionId?: GUID | ''; + stageId?: GUID | ''; + season?: number | ''; +} + +/** + * Maps the active HU-85 scope (per tournament / per season / all-time) to the + * exact `Scorer/by-player` query params, so that: + * - `tournament` sends `tournamentId` (plus the optional division/stage refinement), + * - `season` sends `Season` only, + * - `allTime` sends neither, yielding the all-time ranking. + * + * Empty-string form values are normalised to `undefined` so they are dropped + * from the request instead of being sent as blanks. + */ +export const buildScorerScopeParams = ( + scope: ScorerScope, + input: ScorerScopeInput +): ScorerScopeParams => { + switch (scope) { + case 'season': { + const season = + input.season === '' || input.season == null + ? undefined + : Number(input.season); + return { season }; + } + case 'allTime': + return {}; + case 'tournament': + default: + return { + tournamentId: input.tournamentId || undefined, + divisionId: input.divisionId || undefined, + stageId: input.stageId || undefined, + }; + } +}; diff --git a/Club12-WebClient/src/modules/season/context/season.context.test.tsx b/Club12-WebClient/src/modules/season/context/season.context.test.tsx new file mode 100644 index 0000000..fcacca8 --- /dev/null +++ b/Club12-WebClient/src/modules/season/context/season.context.test.tsx @@ -0,0 +1,83 @@ +import { act, renderHook } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import Swal from 'sweetalert2'; +import { ErrorProvider } from '@/modules/error/context/error.context'; +import { SeasonProvider } from '@/modules/season/context/season.context'; +import { useSeason } from '@/modules/season/hook/season.hook'; +import { seasonService } from '@/modules/season/service/season.service'; +import type { GUID } from '@/modules/core/types/types'; + +vi.mock('@/modules/season/service/season.service'); +vi.mock('sweetalert2', () => ({ + default: { + fire: vi.fn(), + getContainer: vi.fn().mockReturnValue(null), + }, +})); + +const mockedPutSeasonById = vi.mocked(seasonService.putSeasonById); +const mockedDeleteSeasonById = vi.mocked(seasonService.deleteSeasonById); +const mockedSwalFire = vi.mocked(Swal.fire); + +const SEASON_ID = '66666666-6666-6666-6666-666666666666' as GUID; + +const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('SeasonProvider — no duplicate success toast', () => { + /** + * AdminSeasonDetailPage / SeasonsPage already show their own confirmation + * for these actions. The context used to ALSO fire a toast, so the user saw + * two modals with the same message for one action. + */ + it('does not fire its own toast after putSeasonById succeeds (204)', async () => { + mockedPutSeasonById.mockResolvedValueOnce({ status: 204 } as never); + + const { result } = renderHook(() => useSeason(), { wrapper }); + await act(async () => { + await result.current.putSeasonById(SEASON_ID, { + name: 'Temporada XXVI', + } as never); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('does not fire its own toast after putSeasonById succeeds (200)', async () => { + mockedPutSeasonById.mockResolvedValueOnce({ + status: 200, + data: { id: SEASON_ID, slug: 's', name: 'Temporada XXVI', year: 2026, tournaments: [] }, + } as never); + + const { result } = renderHook(() => useSeason(), { wrapper }); + await act(async () => { + await result.current.putSeasonById(SEASON_ID, { + name: 'Temporada XXVI', + } as never); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('does not fire its own toast after deleteSeasonById succeeds', async () => { + mockedDeleteSeasonById.mockResolvedValueOnce({ status: 204 } as never); + + const { result } = renderHook(() => useSeason(), { wrapper }); + await act(async () => { + await result.current.deleteSeasonById(SEASON_ID); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); +}); diff --git a/Club12-WebClient/src/modules/season/context/season.context.tsx b/Club12-WebClient/src/modules/season/context/season.context.tsx new file mode 100644 index 0000000..8eef7af --- /dev/null +++ b/Club12-WebClient/src/modules/season/context/season.context.tsx @@ -0,0 +1,235 @@ +import { AxiosResponse } from 'axios'; +import { + createContext, + ReactNode, + useEffect, + useState, + useCallback, + useMemo, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useError } from '@/modules/error/hooks/error.hock'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { seasonService } from '@/modules/season/service/season.service'; +import { + IAddSeasonRequest, + ISeasonContextProps, + IPutSeasonRequest, + ISeasonResponse, + SeasonFiltered, +} from '@/modules/season/type/season'; +import { FetchOptions, GUID } from '@/modules/core/types/types'; +import { upsertListById } from '@/modules/core/utils/synchronizeStates'; +import { seasonKeys } from '@/modules/season/queryKeys'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +export const SeasonContext = createContext( + undefined +); + +export const SeasonProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [season, setSeason] = useState(null); + const [seasons, setSeasons] = useState(null); + + const { setMessage } = useError(); + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addSeasonMutation = useMutation({ + mutationFn: seasonService.addSeason, + }); + + const putSeasonMutation = useMutation({ + mutationFn: ({ id, season }: { id: GUID; season: IPutSeasonRequest }) => + seasonService.putSeasonById(id, season), + }); + + const deleteSeasonMutation = useMutation({ + mutationFn: seasonService.deleteSeasonById, + }); + + useEffect(() => { + if (!season) return; + setSeasons(prev => upsertListById(prev, season)); + }, [season]); + + const addSeason = useCallback( + async (season: IAddSeasonRequest): Promise => { + try { + const res: AxiosResponse = + await addSeasonMutation.mutateAsync(season); + + if (res) { + setSeason(res.data); + queryClient.setQueryData(seasonKeys.byId(res.data.id), res); + await queryClient.invalidateQueries({ queryKey: seasonKeys.all }); + setMessage(res.status, ['La temporada fue creada exitosamente.']); + } + + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addSeasonMutation, queryClient, setMessage, handleUnknownError] + ); + + const putSeasonById = useCallback( + async ( + id: GUID, + season: IPutSeasonRequest + ): Promise => { + try { + const res: AxiosResponse = + await putSeasonMutation.mutateAsync({ id, season }); + + // Success feedback belongs to the calling page (AdminSeasonDetailPage + // shows its own confirmation) — a toast here too means two modals. + if (res) { + if (res.status === HttpStatus.NoContent) { + const currentSeason = + seasons?.find(existingSeason => existingSeason.id === id) ?? null; + const updatedSeason: ISeasonResponse = { + id, + slug: currentSeason?.slug ?? '', + name: season.name ?? currentSeason?.name ?? '', + year: + season.year !== undefined + ? season.year + : (currentSeason?.year ?? null), + tournaments: currentSeason?.tournaments ?? [], + }; + setSeason(updatedSeason); + setSeasons(prev => upsertListById(prev, updatedSeason)); + await queryClient.invalidateQueries({ queryKey: seasonKeys.all }); + return updatedSeason; + } else if (res.data) { + setSeason(res.data); + setSeasons(prev => upsertListById(prev, res.data)); + queryClient.setQueryData(seasonKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: seasonKeys.all }); + return res.data; + } + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putSeasonMutation, queryClient, seasons, handleUnknownError] + ); + + const getSeasonsByFiltered = useCallback( + async ( + filter: SeasonFiltered, + options?: FetchOptions + ): Promise => { + try { + // The seasons endpoint returns a plain array (seasons are few, no + // pagination), so the list is `res.data` itself — not a `.items` page. + const res: AxiosResponse = + await queryClient.fetchQuery({ + queryKey: seasonKeys.list(filter), + queryFn: async () => await seasonService.getSeasonsByFiltered(filter), + }); + + if (res) { + setSeasons(res.data); + } + return res.data; + } catch (error: unknown) { + if (!options?.silent) handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const getSeasonById = useCallback( + async ( + idOrSlug: string, + options?: FetchOptions + ): Promise => { + try { + if (!options?.force) { + const existingSeason = seasons?.find( + e => e.id === idOrSlug || e.slug === idOrSlug + ); + + if (existingSeason) { + setSeason(existingSeason); + return existingSeason; + } + } else { + // Drop the cached query result too, or fetchQuery below would just + // hand back the same stale data the local-list check would have. + await queryClient.invalidateQueries({ + queryKey: seasonKeys.byId(idOrSlug), + }); + } + + const res: AxiosResponse = + await queryClient.fetchQuery({ + queryKey: seasonKeys.byId(idOrSlug), + queryFn: async () => await seasonService.getSeasonById(idOrSlug), + }); + + if (res) { + setSeason(res.data); + } + + return res.data; + } catch (error: unknown) { + if (!options?.silent) handleUnknownError(error); + } + }, + [seasons, queryClient, handleUnknownError] + ); + + const deleteSeasonById = useCallback( + async (id: GUID): Promise => { + try { + await deleteSeasonMutation.mutateAsync(id); + setSeason(null); + setSeasons(prev => (prev ? prev.filter(e => e.id !== id) : null)); + queryClient.removeQueries({ queryKey: seasonKeys.byId(id) }); + await queryClient.invalidateQueries({ queryKey: seasonKeys.all }); + // Success feedback belongs to the calling page (SeasonsPage shows its + // own "¡Eliminada!" confirmation) — a toast here too means two modals. + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [deleteSeasonMutation, queryClient, handleUnknownError] + ); + + const container: ISeasonContextProps = useMemo( + () => ({ + season, + seasons, + addSeason, + getSeasonsByFiltered, + getSeasonById, + putSeasonById, + deleteSeasonById, + }), + [ + season, + seasons, + addSeason, + getSeasonsByFiltered, + getSeasonById, + putSeasonById, + deleteSeasonById, + ] + ); + + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/season/hook/season.hook.ts b/Club12-WebClient/src/modules/season/hook/season.hook.ts new file mode 100644 index 0000000..53ba465 --- /dev/null +++ b/Club12-WebClient/src/modules/season/hook/season.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { SeasonContext } from '@/modules/season/context/season.context'; + +export const useSeason = () => { + const context = useContext(SeasonContext); + if (!context) { + throw new Error('useSeason must be used within a SeasonProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/season/queryKeys.ts b/Club12-WebClient/src/modules/season/queryKeys.ts new file mode 100644 index 0000000..b12e95f --- /dev/null +++ b/Club12-WebClient/src/modules/season/queryKeys.ts @@ -0,0 +1,7 @@ +import { SeasonFiltered } from '@/modules/season/type/season'; + +export const seasonKeys = { + all: ['season'] as const, + list: (filter?: SeasonFiltered) => ['season', 'list', filter ?? null] as const, + byId: (idOrSlug: string) => ['season', 'byId', idOrSlug] as const, +}; diff --git a/Club12-WebClient/src/modules/season/service/season.service.ts b/Club12-WebClient/src/modules/season/service/season.service.ts new file mode 100644 index 0000000..ba2e95b --- /dev/null +++ b/Club12-WebClient/src/modules/season/service/season.service.ts @@ -0,0 +1,71 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GUID } from '@/modules/core/types/types'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + IAddSeasonRequest, + IPutSeasonRequest, + ISeasonResponse, + SeasonFiltered, +} from '@/modules/season/type/season'; + +/** + * Service for managing seasons ("Temporadas"). + */ +export const seasonService = { + /** + * Adds a new season. + * @param {IAddSeasonRequest} season - The season details to add. + * @returns {Promise>} The server response. + */ + addSeason: async ( + season: IAddSeasonRequest + ): Promise> => + await sendPost(routes.seasons, season), + + /** + * Updates an existing season. + * @param {string} id - The ID of the season to update. + * @param {IPutSeasonRequest} season - The updated season details. + * @returns {Promise>} The server response. + */ + putSeasonById: async ( + id: GUID, + season: IPutSeasonRequest + ): Promise> => + await sendPut(`${routes.seasons}/${id}`, season), + + /** + * Retrieves seasons based on the provided filters. + * @param {SeasonFiltered} filter - The filters to apply when retrieving seasons. + * @returns {Promise>} The server response — a plain array of seasons (no pagination). + */ + getSeasonsByFiltered: async ( + filter: SeasonFiltered + ): Promise> => + await sendGet(routes.seasons, withTablePageSize(filter)), + + /** + * Retrieves a season by its ID or its public slug. + * @param {string} idOrSlug - The ID or slug of the season to retrieve. + * @returns {Promise>} The server response containing the season details. + */ + getSeasonById: async ( + idOrSlug: string + ): Promise> => + await sendGet(`${routes.seasons}/${idOrSlug}`), + + /** + * Deletes a season by its ID. + * @param {string} id - The ID of the season to delete. + * @returns {Promise>} The server response. + */ + deleteSeasonById: async (id: GUID): Promise> => + await sendDelete(`${routes.seasons}/${id}`), +}; diff --git a/Club12-WebClient/src/modules/season/type/season.d.ts b/Club12-WebClient/src/modules/season/type/season.d.ts new file mode 100644 index 0000000..dcfcac9 --- /dev/null +++ b/Club12-WebClient/src/modules/season/type/season.d.ts @@ -0,0 +1,131 @@ +import { FetchOptions, Filtered, GUID } from '@/modules/core/types/types'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; +import { TournamentStatus } from '@/modules/core/enum/tournament/tournamentStatus'; + +/** + * A tournament that belongs to a season, as embedded in a {@link ISeasonResponse}. + * A season groups the Masculino and Femenino tournaments played in a given year, + * so each entry carries the {@link TournamentCategory} used to group them in the + * public season view. + * @interface ISeasonTournament + */ +export interface ISeasonTournament { + /** The unique identifier of the tournament. */ + id: GUID; + /** The name of the tournament. */ + name: string; + /** The unique, URL-friendly identifier used in public tournament links. */ + slug: string; + /** Competitive category (gender) of the tournament (HU-48). */ + category: TournamentCategory; + /** The tournament's lifecycle status. */ + status: TournamentStatus; +} + +/** + * The response structure for a season ("Temporada"): a named grouping of the + * tournaments played in a year. + * @interface ISeasonResponse + */ +export interface ISeasonResponse { + /** The unique identifier of the season. */ + id: GUID; + /** The name of the season. */ + name: string; + /** The unique, URL-friendly identifier used in public season links. */ + slug: string; + /** The calendar year of the season, when set. */ + year: number | null; + /** The tournaments grouped under this season. */ + tournaments: ISeasonTournament[]; +} + +/** + * The request body structure for adding a new season. + * @interface IAddSeasonRequest + */ +export interface IAddSeasonRequest { + /** The name of the season. */ + name: string; + /** The calendar year of the season (optional). */ + year?: number | null; +} + +/** + * The request body structure for updating an existing season. Every field is + * optional so a caller can update only what changed. + * @interface IPutSeasonRequest + */ +export interface IPutSeasonRequest { + /** The name of the season. */ + name?: string; + /** The calendar year of the season. */ + year?: number | null; +} + +/** + * The structure for filtering/paginating seasons. + * @interface SeasonFiltered + */ +export interface SeasonFiltered extends Filtered { + /** The name of the season. */ + name?: string; +} + +/** + * Context properties and methods for managing season data in a React + * application. These methods interact with the backend for creating, updating, + * fetching, and deleting seasons. + * @interface ISeasonContextProps + */ +export interface ISeasonContextProps { + season: ISeasonResponse | null; + seasons: ISeasonResponse[] | null; + + /** + * Adds a new season to the system. + * @param season The details of the season to add. + * @returns A promise that resolves with the response containing the newly added season. + */ + addSeason(season: IAddSeasonRequest): Promise; + + /** + * Updates an existing season. + * @param id The ID of the season to update. + * @param season The updated season details. + * @returns A promise that resolves with the response containing the updated season. + */ + putSeasonById( + id: GUID, + season: IPutSeasonRequest + ): Promise; + + /** + * Fetches all seasons (a plain array; seasons are few, no pagination). + * @param filter The filter criteria to apply when fetching seasons. + * @returns A promise that resolves with the array of seasons. + */ + getSeasonsByFiltered( + filter: SeasonFiltered, + options?: FetchOptions + ): Promise; + + /** + * Fetches a specific season by its unique ID or its public slug. + * @param idOrSlug The ID or slug of the season to fetch. + * @param options Per-call options; `silent` suppresses the global alert on failure. + * @returns A promise that resolves with the season data. + */ + getSeasonById( + idOrSlug: string, + options?: FetchOptions + ): Promise; + + /** + * Deletes a season by its unique ID. + * @param id The ID of the season to delete. + * @returns A promise resolving to `true` if the season was deleted, `false` + * if the request failed (the global error is already reported either way). + */ + deleteSeasonById(id: GUID): Promise; +} diff --git a/Club12-WebClient/src/modules/stage/context/stage.context.tsx b/Club12-WebClient/src/modules/stage/context/stage.context.tsx new file mode 100644 index 0000000..a335d96 --- /dev/null +++ b/Club12-WebClient/src/modules/stage/context/stage.context.tsx @@ -0,0 +1,311 @@ +import { + createContext, + ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + IAddStageRequest, + IDrawPreviewResult, + IDrawRequest, + IPutStageRequest, + IStageContextProps, + IStageResponse, + StageFiltered, +} from '@/modules/stage/type/stage'; +import { useError } from '@/modules/error/hooks/error.hock'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { upsertListById } from '@/modules/core/utils/synchronizeStates'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { AxiosResponse } from 'axios'; +import { stageService } from '@/modules/stage/service/stage.service'; +import { stageKeys } from '@/modules/stage/queryKeys'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +export const StageContext = createContext( + undefined +); + +export const StageProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [stage, setStage] = useState(null); + const [stages, setStages] = useState(null); + + const { setMessage } = useError(); + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addStageMutation = useMutation({ + mutationFn: stageService.addStage, + }); + + const putStageMutation = useMutation({ + mutationFn: ({ + id, + stageRequest, + }: { + id: GUID; + stageRequest: IPutStageRequest; + }) => stageService.putStageById(id, stageRequest), + }); + + const deleteStageMutation = useMutation({ + mutationFn: stageService.deleteStagesById, + }); + + const assignTeamsMutation = useMutation({ + mutationFn: ({ + id, + teamIds, + auto, + }: { + id: GUID; + teamIds: GUID[]; + auto: boolean; + }) => stageService.assignTeamsToStage(id, teamIds, auto), + }); + + const unassignTeamsMutation = useMutation({ + mutationFn: ({ id, teamIds }: { id: GUID; teamIds: GUID[] }) => + stageService.unassignTeamsFromStage(id, teamIds), + }); + + const seedKnockoutStageMutation = useMutation({ + mutationFn: stageService.seedKnockoutStage, + }); + + const previewDrawMutation = useMutation({ + mutationFn: ({ id, body }: { id: GUID; body: IDrawRequest }) => + stageService.previewDraw(id, body), + }); + + const commitDrawMutation = useMutation({ + mutationFn: ({ id, body }: { id: GUID; body: IDrawRequest }) => + stageService.commitDraw(id, body), + }); + + useEffect(() => { + if (!stage) return; + + setStages(prev => upsertListById(prev, stage)); + }, [stage]); + + const addStage = useCallback( + async (stage: IAddStageRequest): Promise => { + try { + const res: AxiosResponse = + await addStageMutation.mutateAsync(stage); + if (res && res.data) { + setStage(res.data); + queryClient.setQueryData(stageKeys.byId(res.data.id), res); + await queryClient.invalidateQueries({ queryKey: stageKeys.list() }); + setMessage(res.status, ['Fase creada exitosamente']); + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addStageMutation, queryClient, setMessage, handleUnknownError] + ); + + const putStageById = useCallback( + async ( + id: GUID, + stageRequest: IPutStageRequest + ): Promise => { + try { + const res: AxiosResponse = + await putStageMutation.mutateAsync({ id, stageRequest }); + setStage(res.data); + queryClient.setQueryData(stageKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: stageKeys.list() }); + return true; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putStageMutation, queryClient, handleUnknownError] + ); + + const getStageById = useCallback( + async (idOrSlug: string): Promise => { + try { + const existingStage: IStageResponse | undefined = stages?.find( + e => e.id === idOrSlug || e.slug === idOrSlug + ); + + if (existingStage) { + setStage(existingStage); + return existingStage; + } + + const res: AxiosResponse = await queryClient.fetchQuery( + { + queryKey: stageKeys.byId(idOrSlug), + queryFn: async () => await stageService.getStagesById(idOrSlug), + } + ); + + if (res && res.data) { + setStage(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [stages, queryClient, handleUnknownError] + ); + + const getStagesByFilters = useCallback( + async ( + filter: StageFiltered + ): Promise | void> => { + try { + const res = await queryClient.fetchQuery({ + queryKey: stageKeys.list(filter), + queryFn: async () => await stageService.getStagesByFilters(filter), + }); + + if (res?.data?.items) { + setStages(res.data.items); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const deleteStagesById = useCallback( + async (id: GUID): Promise => { + try { + await deleteStageMutation.mutateAsync(id); + setStage(null); + setStages(prev => (prev ? prev.filter(e => e.id !== id) : null)); + queryClient.removeQueries({ queryKey: stageKeys.byId(id) }); + await queryClient.invalidateQueries({ queryKey: stageKeys.list() }); + setMessage(HttpStatus.NoContent, ['La etapa ha sido eliminada.']); + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [deleteStageMutation, queryClient, setMessage, handleUnknownError] + ); + + const assignTeamsToStage = useCallback( + async (id: GUID, teamIds: GUID[], auto = false): Promise => { + try { + const res = await assignTeamsMutation.mutateAsync({ id, teamIds, auto }); + if (res) { + await queryClient.invalidateQueries({ queryKey: stageKeys.list() }); + return true; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [assignTeamsMutation, queryClient, handleUnknownError] + ); + + const unassignTeamsFromStage = useCallback( + async (id: GUID, teamIds: GUID[]): Promise => { + try { + const res = await unassignTeamsMutation.mutateAsync({ id, teamIds }); + if (res) { + await queryClient.invalidateQueries({ queryKey: stageKeys.list() }); + return true; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [unassignTeamsMutation, queryClient, handleUnknownError] + ); + + const seedKnockoutStage = useCallback( + async (id: GUID): Promise => { + try { + const res = await seedKnockoutStageMutation.mutateAsync(id); + if (res) { + await queryClient.invalidateQueries({ queryKey: stageKeys.list() }); + return true; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [seedKnockoutStageMutation, queryClient, handleUnknownError] + ); + + const previewDraw = useCallback( + async (id: GUID, body: IDrawRequest): Promise => { + try { + const res = await previewDrawMutation.mutateAsync({ id, body }); + if (res && res.data) { + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [previewDrawMutation, handleUnknownError] + ); + + const commitDraw = useCallback( + async (id: GUID, body: IDrawRequest): Promise => { + try { + const res = await commitDrawMutation.mutateAsync({ id, body }); + if (res) { + await queryClient.invalidateQueries({ queryKey: stageKeys.list() }); + return true; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [commitDrawMutation, queryClient, handleUnknownError] + ); + + const container: IStageContextProps = useMemo( + () => ({ + stage, + stages, + addStage, + putStageById, + getStagesByFilters, + getStageById, + deleteStagesById, + assignTeamsToStage, + unassignTeamsFromStage, + seedKnockoutStage, + previewDraw, + commitDraw, + }), + [ + stage, + stages, + addStage, + putStageById, + getStagesByFilters, + getStageById, + deleteStagesById, + assignTeamsToStage, + unassignTeamsFromStage, + seedKnockoutStage, + previewDraw, + commitDraw, + ] + ); + + return ( + {children} + ); +}; diff --git a/Club12-WebClient/src/modules/stage/hook/stage.hook.ts b/Club12-WebClient/src/modules/stage/hook/stage.hook.ts new file mode 100644 index 0000000..a3bf617 --- /dev/null +++ b/Club12-WebClient/src/modules/stage/hook/stage.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from "react"; +import { StageContext } from "@/modules/stage/context/stage.context"; + +export const useStage = () => { + const context = useContext(StageContext); + if (!context) { + throw new Error("useStage must be used within a StageProvider"); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/stage/queryKeys.test.ts b/Club12-WebClient/src/modules/stage/queryKeys.test.ts new file mode 100644 index 0000000..65c40ca --- /dev/null +++ b/Club12-WebClient/src/modules/stage/queryKeys.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { stageKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; +import { StageFiltered } from '@/modules/stage/type/stage'; + +describe('stageKeys', () => { + const id: GUID = '55555555-5555-5555-5555-555555555555'; + + it('list() returns the bare list literal with no trailing undefined', () => { + expect(stageKeys.list()).toEqual(['stage', 'list']); + }); + + it('list(filter) returns the filtered list literal', () => { + const filter: StageFiltered = { pageNumber: 1 }; + expect(stageKeys.list(filter)).toEqual(['stage', 'list', filter]); + }); + + it('byId(id) returns the by-id literal', () => { + expect(stageKeys.byId(id)).toEqual(['stage', 'byId', id]); + }); +}); diff --git a/Club12-WebClient/src/modules/stage/queryKeys.ts b/Club12-WebClient/src/modules/stage/queryKeys.ts new file mode 100644 index 0000000..dcef8e8 --- /dev/null +++ b/Club12-WebClient/src/modules/stage/queryKeys.ts @@ -0,0 +1,9 @@ +import { StageFiltered } from '@/modules/stage/type/stage'; + +export const stageKeys = { + list: (filter?: StageFiltered) => + filter === undefined + ? (['stage', 'list'] as const) + : (['stage', 'list', filter] as const), + byId: (idOrSlug: string) => ['stage', 'byId', idOrSlug] as const, +}; diff --git a/Club12-WebClient/src/modules/stage/service/stage.service.ts b/Club12-WebClient/src/modules/stage/service/stage.service.ts new file mode 100644 index 0000000..55c5858 --- /dev/null +++ b/Club12-WebClient/src/modules/stage/service/stage.service.ts @@ -0,0 +1,136 @@ +import { GUID, GenericResponsePagination } from '@/modules/core/types/types'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { + IAddStageRequest, + IDrawPreviewResult, + IDrawRequest, + IPutStageRequest, + IStageResponse, + StageFiltered, +} from '@/modules/stage/type/stage'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import routes from '@/modules/core/constants/routes'; +import { AxiosResponse } from 'axios'; +import { IMatchResponse } from '@/modules/match/type/match.d'; + +export const stageService = { + /** + * Creates a new stage. + * @param {IAddStageRequest} stage - The data required to create a new stage. + * @returns {Promise>} The response containing the created stage. + */ + addStage: async ( + stage: IAddStageRequest + ): Promise> => + sendPost(routes.stages, stage), + + /** + * Updates a stage by its ID. + * @param {GUID} id - The unique identifier of the stage to update. + * @param {IPutStageRequest} stageRequest - The updated stage data. + * @returns {Promise>} The response containing the updated stage. + */ + putStageById: async ( + id: GUID, + stageRequest: IPutStageRequest + ): Promise> => + sendPut(`${routes.stages}/${id}`, stageRequest), + + /** + * Retrieves a stage by its ID or its public slug. + * @param {string} idOrSlug - The ID or slug of the stage to retrieve. + * @returns {Promise>} The response containing the requested stage. + */ + getStagesById: async ( + idOrSlug: string + ): Promise> => + sendGet(`${routes.stages}/${idOrSlug}`), + + /** + * Retrieves a paginated list of stages based on filters. + * @param {StageFiltered} filter - The filter criteria for fetching stages. + * @returns {Promise>>} + * The response containing a paginated list of stages. + */ + getStagesByFilters: async ( + filter: StageFiltered + ): Promise>> => + sendGet(routes.stages, withTablePageSize(filter)), + + /** + * Deletes a stage by its ID. + * @param {GUID} id - The unique identifier of the stage to delete. + * @returns {Promise>} The response indicating the deletion result. + */ + deleteStagesById: async (id: GUID): Promise> => + sendDelete(`${routes.stages}/${id}`), + + /** + * Assigns one or more teams to a stage. + * @param {GUID} id - The unique identifier of the stage. + * @param {GUID[]} teamIds - The teams to assign (ignored when auto is true). + * @param {boolean} auto - When true, fills available slots automatically. + * @returns {Promise>} The response confirming the assignment. + */ + assignTeamsToStage: async ( + id: GUID, + teamIds: GUID[], + auto = false + ): Promise> => + sendPost(`${routes.stages}/${id}/assign-team`, { teamIds, auto }), + + /** + * Unassigns one or more teams from a stage (removes them from that zone's + * group). Used when moving a team to another zone or clearing its slot. + * @param {GUID} id - The stage to remove the teams from. + * @param {GUID[]} teamIds - The teams to unassign. + * @returns {Promise>} The response confirming the removal. + */ + unassignTeamsFromStage: async ( + id: GUID, + teamIds: GUID[] + ): Promise> => + sendDelete(`${routes.stages}/${id}/unassign-team`, undefined, { teamIds }), + + /** + * Seeds an elimination stage's already-generated matches from the + * division's group-stage standings. + * @param {GUID} id - The elimination stage to seed. + * @returns {Promise>} The response confirming the seeding. + */ + seedKnockoutStage: async (id: GUID): Promise> => + sendPost(`${routes.stages}/${id}/seed`), + + /** + * Previews a groupless bracket's first-round draw without persisting it. + * @param {GUID} id - The first-round bracket stage to preview a draw for. + * @param {IDrawRequest} body - The draw mode and, for manual seeding, the explicit order. + * @returns {Promise>} The previewed pairing and draw token. + */ + previewDraw: async ( + id: GUID, + body: IDrawRequest + ): Promise> => + sendPost(`${routes.stages}/${id}/preview-draw`, body), + + /** + * Commits a groupless bracket's first-round draw from a previewed token + * (random) or an explicit order (manual). The response's seeded matches + * (200) do NOT carry hydrated `homeTeam`/`visitorTeam` objects (same as the + * pre-existing `/seed` endpoint) — callers must refetch the stage/bracket + * for display names rather than reading them off this response. + * @param {GUID} id - The first-round bracket stage to draw. + * @param {IDrawRequest} body - The draw mode, draw token (random) or manual order. + * @returns {Promise>} The seeded matches. + */ + commitDraw: async ( + id: GUID, + body: IDrawRequest + ): Promise> => + sendPost(`${routes.stages}/${id}/draw`, body), +}; diff --git a/Club12-WebClient/src/modules/stage/type/stage.ts b/Club12-WebClient/src/modules/stage/type/stage.ts new file mode 100644 index 0000000..ea2ecbd --- /dev/null +++ b/Club12-WebClient/src/modules/stage/type/stage.ts @@ -0,0 +1,454 @@ +import { Filtered, GenericResponsePagination, GUID } from '@/modules/core/types/types'; + +/** + * Represents the context properties and operations available for managing stages. + */ +export interface IStageContextProps { + /** + * The currently selected stage or null if none is selected. + */ + stage: IStageResponse | null; + + /** + * The list of all stages or null if not loaded. + */ + stages: IStageResponse[] | null; + + /** + * Adds a new stage. + * @param {IAddStageRequest} division - The data required to create a new stage. + * @returns {Promise} A promise resolving to the created stage or void on failure. + */ + addStage(stage: IAddStageRequest): Promise; + + /** + * Updates an existing stage by its ID. + * @param {GUID} id - The unique identifier of the stage to update. + * @param {IPutStageRequest} division - The updated stage data. + * @returns {Promise} A promise resolving to true if the update succeeded, or void on failure. + */ + putStageById(id: GUID, division: IPutStageRequest): Promise; + + /** + * Retrieves a stage by its ID or its public slug. + * @param {string} idOrSlug - The ID or slug of the stage to retrieve. + * @returns {Promise} A promise resolving to the requested stage or void on failure. + */ + getStageById(idOrSlug: string): Promise; + + /** + * Retrieves a paginated list of stages based on filters. + * @param {StageFiltered} filter - The filter criteria for fetching stages. + * @returns {Promise | void>} A promise resolving to the paginated list or void on failure. + */ + getStagesByFilters( + filter: StageFiltered + ): Promise | void>; + + /** + * Deletes a stage by its ID. + * @param {GUID} id - The unique identifier of the stage to delete. + * @returns {Promise} A promise that resolves when the stage is deleted. + */ + deleteStagesById(id: GUID): Promise; + + /** + * Assigns one or more teams to a stage, either manually by team id or + * automatically based on available slots. + * @param {GUID} id - The unique identifier of the stage. + * @param {GUID[]} teamIds - The teams to assign (ignored when auto is true). + * @param {boolean} auto - When true, fills available slots automatically. + * @returns {Promise} A promise resolving to true if the assignment succeeded, or void on failure. + */ + assignTeamsToStage( + id: GUID, + teamIds: GUID[], + auto?: boolean + ): Promise; + + /** + * Unassigns one or more teams from a stage (removes them from that zone's + * group), e.g. when moving a team to another zone. + * @param {GUID} id - The stage to remove the teams from. + * @param {GUID[]} teamIds - The teams to unassign. + * @returns {Promise} A promise resolving to true on success, or void on failure. + */ + unassignTeamsFromStage(id: GUID, teamIds: GUID[]): Promise; + + /** + * Seeds an elimination stage's already-generated matches from the + * division's group-stage standings, in classic bracket seed order. + * @param {GUID} id - The elimination stage to seed. + * @returns {Promise} A promise resolving to true if seeding succeeded, or void on failure. + */ + seedKnockoutStage(id: GUID): Promise; + + /** + * Computes a first-round pairing for a groupless bracket without + * persisting it, returning a signed token that replays the exact same + * order on commit (preview == commit guarantee). + * @param {GUID} id - The first-round bracket stage to preview a draw for. + * @param {IDrawRequest} body - The draw mode and, for manual seeding, the explicit order. + * @returns {Promise} The previewed pairing and draw token, or void on failure. + */ + previewDraw(id: GUID, body: IDrawRequest): Promise; + + /** + * Commits a bracket draw from a previewed token (random) or an explicit + * order (manual), stamping `drawnAt` and writing a `PlayoffDraw` audit entry. + * @param {GUID} id - The first-round bracket stage to draw. + * @param {IDrawRequest} body - The draw mode, draw token (random) or manual order. + * @returns {Promise} A promise resolving to true on success, or void on failure. + */ + commitDraw(id: GUID, body: IDrawRequest): Promise; +} + +/** Random shuffles the roster; Manual seeds the admin-specified order. */ +export const DrawMode = { + Random: 'Random', + Manual: 'Manual', +} as const; + +export type DrawMode = (typeof DrawMode)[keyof typeof DrawMode]; + +/** + * The request body for both the preview and the commit draw endpoints. + * @interface IDrawRequest + */ +export interface IDrawRequest { + mode: DrawMode; + /** Required for `Manual` commit; a `Random` preview never sends one. */ + manualOrder?: GUID[]; + /** Required for a `Random` commit — the token returned by the preceding preview. */ + drawToken?: string; +} + +/** One previewed bracket pairing; `visitorTeamId` is null when the slot is a bye. */ +export interface IDrawPairPreview { + homeTeamId: GUID; + visitorTeamId?: GUID | null; +} + +/** + * The response of a preview-draw call: the candidate pairing plus the token + * that replays the exact same order when submitted to the commit endpoint. + * @interface IDrawPreviewResult + */ +export interface IDrawPreviewResult { + pairs: IDrawPairPreview[]; + drawToken: string; +} + +/** + * Represents the response data for a stage. + */ +export interface IStageResponse { + /** + * The unique identifier of the stage. + * @type {GUID} + */ + id: GUID; + + /** + * The name of the stage. + * @type {string} + */ + name: string; + + /** + * The unique, URL-friendly identifier used in public stage links. + * @type {string} + */ + slug: string; + + /** + * Optional description providing additional details about the stage. + * @type {string | null} + */ + description?: string | null; + + /** + * The type of the stage (e.g., "Group", "QuarterFinal"). + * @type {string} + */ + stageType: StageType; + + /** + * Indicates whether the stage is currently active. + * @type {boolean} + */ + isActive: boolean; + + /** + * Indicates whether this stage is an elimination stage. + * @type {boolean} + */ + isElimination: boolean; + + /** + * The start date of the stage. + * @type {string} ISO 8601 format date. + */ + startDate: string; + + /** + * The end date of the stage. + * @type {string} ISO 8601 format date. + */ + endDate: string; + + /** + * The ID of the division to which the stage belongs. + * @type {GUID} + */ + divisionId: GUID; + + /** + * The order of the stage within the stage. + * @type {number} + */ + order: number; + + /** + * Groups multiple parallel elimination brackets under the same division + * (e.g. an admin-named "Copa de Oro" / "Copa de Plata" pair). Null means + * the stage belongs to the division's single/default bracket. This name + * is entirely admin-defined — never hardcoded. + * @type {string | null} + */ + bracketName?: string | null; + + /** + * Number of games in a series between two teams at this round + * (1, 3, 5, or 7). 1 means a single match decides the round. + * @type {number} + */ + bestOf: number; + + /** + * How many times each pair of teams plays within this group stage + * (1 = single round-robin, 2 = double, ...). Only meaningful for a + * Group stage. + * @type {number} + */ + roundRobinLegs: number; + + /** + * When this bracket's seeding draw was committed (initial or re-draw), or + * null when it has not been drawn yet. Set only on the bracket's + * first-round stage. Public — safe to show on the unauthenticated bracket + * view as "Sorteo realizado el [fecha]". + * @type {string | null} + */ + drawnAt?: string | null; +} + +/** + * Represents the payload for updating an existing stage. + */ +export interface IPutStageRequest { + /** + * Optional description providing additional details about the stage. + * @type {string | null} + */ + description?: string | null; + + /** + * Indicates whether the stage is currently active. + * @type {boolean | null} + */ + isActive?: boolean | null; +} + +/** + * Represents the payload for creating a new stage. + */ +export interface IAddStageRequest { + /** + * The name of the stage (e.g., "Group A", "Quarterfinals"). + * @type {string} + */ + name: string; + + /** + * Optional description providing additional details about the stage. + * @type {string | null} + */ + description?: string | null; + + /** + * The type of the stage, such as "Group" or "Elimination". + * @type {StageType} + */ + stageType: StageType; + + /** + * Indicates whether the stage is currently active. + * Defaults to true if not provided. + * @type {boolean | null} + */ + isActive?: boolean | null; + + /** + * Indicates whether this stage is an elimination stage. + * Defaults to false if not provided. + * @type {boolean | null} + */ + isElimination?: boolean | null; + + /** + * The starting date of the stage. + * @type {Date} + */ + startDate: Date; + + /** + * The ending date of the stage. + * @type {Date} + */ + endDate: Date; + + /** + * The ID of the division to which this stage belongs. + * @type {GUID} + */ + divisionId: GUID; + + /** + * Groups multiple parallel elimination brackets under the same division + * (e.g. an admin-named "Copa de Oro" / "Copa de Plata" pair). Free text, + * admin-defined; omit for the division's single/default bracket. + * @type {string | null} + */ + bracketName?: string | null; + + /** + * Number of games in a series between two teams at this round + * (1, 3, 5, or 7). Defaults to 1 (single match decides the round). + * @type {number} + */ + bestOf?: number; + + /** + * How many times each pair of teams plays within this group stage + * (1 = single round-robin, 2 = double, ...). Defaults to 1. + * @type {number} + */ + roundRobinLegs?: number; +} +/** + * Represents the filter and pagination parameters + * to retrieve a filtered list of stages. + */ +export interface StageFiltered extends Filtered { + /** + * Optional filter by Tournament unique identifier. + * @type {GUID | null} + */ + tournamentId?: GUID | null; + + /** + * Optional filter by Division unique identifier. + * @type {GUID | null} + */ + divisionId?: GUID | null; + + /** + * Optional filter by stage type (e.g., "Group", "QuarterFinal"). + * @type {StageType | null} + */ + stageType?: StageType | null; + + /** + * Optional filter by active status. + * @type {boolean | null} + */ + isActive?: boolean | null; + + /** + * Optional filter to specify if the stage is elimination or not. + * @type {boolean | null} + */ + isElimination?: boolean | null; + + /** + * Optional filter by stage name (supports partial matching). + * @type {string | null} + */ + name?: string | null; + + /** + * Optional filter to get stages starting on or after this date (ISO 8601 format). + * @type {string | null} + */ + startDate?: string | null; + + /** + * Optional filter to get stages ending on or before this date (ISO 8601 format). + * @type {string | null} + */ + endDate?: string | null; +} + +/** + * Represents the different types of stages in a tournament. + */ +export enum StageType { + /** + * A group stage, typically used for round-robin matches. + */ + Group = 'Group', + + /** + * A knockout stage with 16 teams (Round of 16). + */ + RoundOf16 = 'RoundOf16', + + /** + * A knockout stage with 8 teams (Quarterfinals). + */ + QuarterFinal = 'QuarterFinal', + + /** + * A knockout stage with 4 teams (Semifinals). + */ + SemiFinal = 'SemiFinal', + + /** + * A match to determine the third-place winner. + */ + ThirdPlace = 'ThirdPlace', + + /** + * The final match of the tournament. + */ + Final = 'Final', +} + +export interface IDashboardStage { + stages: IStageResponse[]; +} + +export interface IStageCreateFormState { + name: string; + description: string; + stageType: StageType; + startDate: string; + endDate: string; + isActive: boolean; + isElimination: boolean; + divisionId: GUID | ''; + bracketName: string; + bestOf: number; + roundRobinLegs: number; +} + +export interface IStageEditFormState { + description: string; + isActive: boolean; +} + +export interface IStageListFilters + extends Pick { + divisionId?: GUID; +} diff --git a/Club12-WebClient/src/modules/stage/utils/stageLabel.ts b/Club12-WebClient/src/modules/stage/utils/stageLabel.ts new file mode 100644 index 0000000..5c998eb --- /dev/null +++ b/Club12-WebClient/src/modules/stage/utils/stageLabel.ts @@ -0,0 +1,7 @@ +import { IStageResponse } from '@/modules/stage/type/stage'; +import { translateStageType } from '@/modules/core/utils/translateStageType'; + +export const stageLabel = (stage: IStageResponse): string => { + const typeLabel = translateStageType(stage.stageType); + return stage.bracketName ? `${stage.bracketName} — ${typeLabel}` : typeLabel; +}; diff --git a/Club12-WebClient/src/modules/team/context/team.context.tsx b/Club12-WebClient/src/modules/team/context/team.context.tsx new file mode 100644 index 0000000..4ac946e --- /dev/null +++ b/Club12-WebClient/src/modules/team/context/team.context.tsx @@ -0,0 +1,221 @@ +import { AxiosResponse } from 'axios'; +import { + createContext, + ReactNode, + useEffect, + useState, + useCallback, + useMemo, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { + FetchOptions, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { teamService } from '@/modules/team/service/team.service'; +import { + IAddTeamRequest, + ITeamContextProps, + IPutTeamRequest, + TeamFiltered, + ITeamResponse, +} from '@/modules/team/type/team.d'; +import { upsertListById } from '@/modules/core/utils/synchronizeStates'; +import { teamKeys } from '@/modules/team/queryKeys'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +export const TeamContext = createContext( + undefined +); + +export const TeamProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [team, setTeam] = useState(null); + const [teams, setTeams] = useState(null); + + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addTeamMutation = useMutation({ + mutationFn: teamService.addTeam, + }); + + const putTeamMutation = useMutation({ + mutationFn: ({ id, data }: { id: GUID; data: IPutTeamRequest }) => + teamService.putTeamById(id, data), + }); + + const putTeamLogoMutation = useMutation({ + mutationFn: ({ id, logo }: { id: GUID; logo: File }) => + teamService.putTeamLogoById(id, logo), + }); + + const deleteTeamMutation = useMutation({ + mutationFn: teamService.deleteTeamById, + }); + + useEffect(() => { + if (!team) return; + setTeams(prev => upsertListById(prev, team)); + }, [team]); + + const addTeam = useCallback( + async (teamData: IAddTeamRequest): Promise => { + try { + const res: AxiosResponse = + await addTeamMutation.mutateAsync(teamData); + if (res) { + setTeam(res.data); + queryClient.setQueryData(teamKeys.byId(res.data.id), res); + await queryClient.invalidateQueries({ queryKey: teamKeys.list() }); + } + return res?.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addTeamMutation, queryClient, handleUnknownError] + ); + + const putTeamById = useCallback( + // Returns whether the update succeeded. A successful PUT answers 204 (no + // body), so the caller can't rely on a returned entity — it must key off + // this boolean to decide whether to close the dialog and refresh. + async (id: GUID, data: IPutTeamRequest): Promise => { + try { + const res: AxiosResponse = + await putTeamMutation.mutateAsync({ + id, + data, + }); + + if (res) { + if (res.status === HttpStatus.NoContent) { + setTeam(prev => + prev && prev.id === id ? { ...prev, ...data } : prev + ); + } else if (res.data) { + setTeam(res.data); + queryClient.setQueryData(teamKeys.byId(id), res); + } + await queryClient.invalidateQueries({ queryKey: teamKeys.list() }); + } + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [putTeamMutation, queryClient, handleUnknownError] + ); + + const putTeamLogoById = useCallback( + async (id: GUID, logo: File): Promise => { + try { + await putTeamLogoMutation.mutateAsync({ id, logo }); + // The logo endpoint returns no body and each upload lands at a new + // unique URL, so the fresh logoUrl is only knowable via a real GET — + // mirrors venue.context.tsx's putVenuePhotoById fix for the same gap. + const res: AxiosResponse = + await teamService.getTeamById(id); + queryClient.setQueryData(teamKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: teamKeys.list() }); + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putTeamLogoMutation, queryClient, handleUnknownError] + ); + + const getTeamsByFiltered = useCallback( + async ( + filter: TeamFiltered, + options?: FetchOptions + ): Promise | void> => { + try { + const res = await queryClient.fetchQuery({ + queryKey: teamKeys.list(filter), + queryFn: async () => await teamService.getTeamsByFiltered(filter), + }); + + if (res?.data?.items) { + setTeams(res.data.items); + return res.data; + } + } catch (error: unknown) { + if (!options?.silent) handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const getTeamById = useCallback( + async (id: string, options?: FetchOptions): Promise => { + try { + const res: AxiosResponse = await queryClient.fetchQuery({ + queryKey: teamKeys.byId(id), + queryFn: async () => await teamService.getTeamById(id), + }); + + if (res) { + setTeam(res.data); + return res.data; + } + } catch (error: unknown) { + if (!options?.silent) handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const deleteTeamById = useCallback( + async (id: GUID): Promise => { + try { + await deleteTeamMutation.mutateAsync(id); + setTeams(prev => prev?.filter(e => e.id !== id) ?? null); + if (team?.id === id) { + setTeam(null); + } + queryClient.removeQueries({ queryKey: teamKeys.byId(id) }); + await queryClient.invalidateQueries({ queryKey: teamKeys.list() }); + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [deleteTeamMutation, queryClient, team, handleUnknownError] + ); + + const container: ITeamContextProps = useMemo( + () => ({ + team, + teams, + addTeam, + getTeamById, + getTeamsByFiltered, + putTeamById, + putTeamLogoById, + deleteTeamById, + }), + [ + team, + teams, + addTeam, + getTeamById, + getTeamsByFiltered, + putTeamById, + putTeamLogoById, + deleteTeamById, + ] + ); + + return ( + {children} + ); +}; diff --git a/Club12-WebClient/src/modules/team/hook/team.hook.ts b/Club12-WebClient/src/modules/team/hook/team.hook.ts new file mode 100644 index 0000000..a0477cd --- /dev/null +++ b/Club12-WebClient/src/modules/team/hook/team.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { TeamContext } from '@/modules/team/context/team.context'; + +export const useTeam = () => { + const context = useContext(TeamContext); + if (!context) { + throw new Error('useTeam must be used within a TeamProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/team/hook/useTeamProfile.ts b/Club12-WebClient/src/modules/team/hook/useTeamProfile.ts new file mode 100644 index 0000000..e84c1d9 --- /dev/null +++ b/Club12-WebClient/src/modules/team/hook/useTeamProfile.ts @@ -0,0 +1,183 @@ +import { useEffect, useState } from 'react'; +import { GUID } from '@/modules/core/types/types'; +import { teamService } from '@/modules/team/service/team.service'; +import { scorerService } from '@/modules/scorer/service/scorer.service'; +import { championService } from '@/modules/champion/service/champion.service'; +import { + TeamMatch, + TeamParticipation, + TeamSummary, +} from '@/modules/team/type/teamProfile.d'; +import { IScorerByPlayerResponse } from '@/modules/scorer/type/scorer.d'; +import { IChampionHistory } from '@/modules/champion/type/champion.d'; + +/** How many top scorers the "Goleadores" block requests/shows. */ +const TOP_SCORERS_LIMIT = 5; + +/** + * The tournaments a team has taken part in (newest first), driving the profile's + * season/tournament selector. Fetches once per team. + */ +export const useTeamParticipations = ( + idOrSlug?: string +): { participations: TeamParticipation[]; loading: boolean } => { + const [participations, setParticipations] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!idOrSlug) return; + let cancelled = false; + + const run = async () => { + setLoading(true); + try { + const res = await teamService.getTeamParticipations(idOrSlug); + if (!cancelled) setParticipations(res.data ?? []); + } catch { + if (!cancelled) setParticipations([]); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [idOrSlug]); + + return { participations, loading }; +}; + +/** + * A team's standing plus fixture for the selected tournament. Both refetch when + * the active tournament changes; `summary` is `null` when the team has no + * standing yet. Skips fetching until a tournament is selected. + */ +export const useTeamStandings = ( + idOrSlug: string | undefined, + tournamentId: GUID | undefined +): { summary: TeamSummary | null; matches: TeamMatch[]; loading: boolean } => { + const [summary, setSummary] = useState(null); + const [matches, setMatches] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!idOrSlug || !tournamentId) { + setSummary(null); + setMatches([]); + return; + } + let cancelled = false; + + const run = async () => { + setLoading(true); + try { + const [summaryRes, matchesRes] = await Promise.all([ + teamService.getTeamSummary(idOrSlug, tournamentId), + teamService.getTeamMatches(idOrSlug, tournamentId), + ]); + if (!cancelled) { + setSummary(summaryRes.data ?? null); + setMatches(matchesRes.data ?? []); + } + } catch { + if (!cancelled) { + setSummary(null); + setMatches([]); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [idOrSlug, tournamentId]); + + return { summary, matches, loading }; +}; + +/** + * The team's top scorers for the selected tournament, reusing the shared scorer + * ranking endpoint. Skips fetching until both the team and tournament are known. + */ +export const useTeamScorers = ( + teamId: GUID | undefined, + tournamentId: GUID | undefined +): { scorers: IScorerByPlayerResponse[]; loading: boolean } => { + const [scorers, setScorers] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!teamId || !tournamentId) { + setScorers([]); + return; + } + let cancelled = false; + + const run = async () => { + setLoading(true); + try { + const res = await scorerService.getScorersByPlayerFiltered({ + teamId, + tournamentId, + pageNumber: 1, + pageSize: TOP_SCORERS_LIMIT, + }); + if (!cancelled) setScorers(res.data?.items ?? []); + } catch { + if (!cancelled) setScorers([]); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [teamId, tournamentId]); + + return { scorers, loading }; +}; + +/** + * The titles this team has won, derived from the public champions history by + * filtering to entries whose champion is this team. Fetches once per team. + */ +export const useTeamTitles = ( + teamId: GUID | undefined +): { titles: IChampionHistory[]; loading: boolean } => { + const [titles, setTitles] = useState([]); + const [loading, setLoading] = useState(false); + + useEffect(() => { + if (!teamId) return; + let cancelled = false; + + const run = async () => { + setLoading(true); + try { + const res = await championService.getChampionsHistory(); + const mine = (res.data ?? []).filter( + entry => entry.championTeam.teamId === teamId + ); + if (!cancelled) setTitles(mine); + } catch { + if (!cancelled) setTitles([]); + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [teamId]); + + return { titles, loading }; +}; diff --git a/Club12-WebClient/src/modules/team/queryKeys.test.ts b/Club12-WebClient/src/modules/team/queryKeys.test.ts new file mode 100644 index 0000000..2f65888 --- /dev/null +++ b/Club12-WebClient/src/modules/team/queryKeys.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { teamKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; +import { TeamFiltered } from '@/modules/team/type/team.d'; + +describe('teamKeys', () => { + const id: GUID = '22222222-2222-2222-2222-222222222222'; + + it('list() returns the bare list literal with no trailing undefined', () => { + expect(teamKeys.list()).toEqual(['team', 'list']); + }); + + it('list(filter) returns the filtered list literal', () => { + const filter: TeamFiltered = { name: 'Boca', pageNumber: 1 }; + expect(teamKeys.list(filter)).toEqual(['team', 'list', filter]); + }); + + it('byId(id) returns the by-id literal', () => { + expect(teamKeys.byId(id)).toEqual(['team', 'byId', id]); + }); +}); diff --git a/Club12-WebClient/src/modules/team/queryKeys.ts b/Club12-WebClient/src/modules/team/queryKeys.ts new file mode 100644 index 0000000..a348183 --- /dev/null +++ b/Club12-WebClient/src/modules/team/queryKeys.ts @@ -0,0 +1,9 @@ +import { TeamFiltered } from '@/modules/team/type/team.d'; + +export const teamKeys = { + list: (filter?: TeamFiltered) => + filter === undefined + ? (['team', 'list'] as const) + : (['team', 'list', filter] as const), + byId: (id: string) => ['team', 'byId', id] as const, +}; diff --git a/Club12-WebClient/src/modules/team/service/team.service.ts b/Club12-WebClient/src/modules/team/service/team.service.ts new file mode 100644 index 0000000..4145b74 --- /dev/null +++ b/Club12-WebClient/src/modules/team/service/team.service.ts @@ -0,0 +1,162 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + IAddTeamRequest, + IPutTeamRequest, + TeamFiltered, + ITeamResponse, +} from '@/modules/team/type/team'; +import { + TeamMatch, + TeamParticipation, + TeamSummary, +} from '@/modules/team/type/teamProfile.d'; + +/** + * Service for managing teams. + */ +export const teamService = { + /** + * Adds a new team. + * @param {IAddTeamRequest} team - The team details to add. + * @returns {Promise>} The server response. + */ + addTeam: async ( + team: IAddTeamRequest + ): Promise> => { + const formData = new FormData(); + formData.append('Name', team.name); + formData.append('ThreeLetterCode', team.threeLetterCode); + formData.append('ShirtColor', team.shirtColor); + if (team.shirtSecondaryColor) { + formData.append('ShirtSecondaryColor', team.shirtSecondaryColor); + } + if (team.shirtTertiaryColor) { + formData.append('ShirtTertiaryColor', team.shirtTertiaryColor); + } + if (team.jerseyStyle) { + formData.append('JerseyStyle', team.jerseyStyle); + } + formData.append('LogoFile', team.logo); + + if (team.tournamentId !== undefined) { + formData.append('TournamentId', team.tournamentId ?? ''); + } + + return await sendPost(routes.teams, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, + + /** + * Updates an existing team. + * @param {string} id - The ID of the team to update. + * @param {IPutTeamRequest} data - The updated team details. + * @returns {Promise>} The server response. + */ + putTeamById: async ( + id: GUID, + data: IPutTeamRequest + ): Promise> => + await sendPut(`${routes.teams}/${id}`, data), + + /** + * Updates the logo for a specific team. + * @param {string} id - The ID of the team to update. + * @param {File} logo - The new logo file. + * @returns {Promise>} The server response. + */ + putTeamLogoById: async (id: GUID, logo: File): Promise> => { + // The backend binds [FromForm] UpdateTeamLogoRequest, which requires both a + // TeamId and the LogoFile — send a proper multipart body (a raw File as the + // request body fails model binding with a 400). + const formData = new FormData(); + formData.append('TeamId', id); + formData.append('LogoFile', logo); + return await sendPut(`${routes.teams}/${id}/logo`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + + /** + * Retrieves teams based on the provided filters. + * @param {TeamFiltered} filters - The filters to apply when retrieving teams. + * @returns {Promise>>} The server response containing the filtered teams. + */ + getTeamsByFiltered: async ( + filters: TeamFiltered + ): Promise>> => + await sendGet(routes.teams, withTablePageSize(filters)), + + /** + * Retrieves a specific team by its ID or its public slug. + * @param {string} idOrSlug - The ID or slug of the team to retrieve. + * @returns {Promise>} The server response containing the team details. + */ + getTeamById: async ( + idOrSlug: string + ): Promise> => + await sendGet(`${routes.teams}/${idOrSlug}`), + + /** + * Deletes a team by its ID. + * @param {string} id - The ID of the team to delete. + * @returns {Promise>} The server response. + */ + deleteTeamById: async (id: GUID): Promise> => + await sendDelete(`${routes.teams}/${id}`), + + /** + * Retrieves a team's standing (position, record, points) within one + * tournament, for the public team profile. Answers `null` when the team has + * no standing yet. + * @param {string} idOrSlug - The id or public slug of the team. + * @param {GUID} [tournamentId] - Scopes the standing to a single tournament. + * @returns {Promise>} The server response. + */ + getTeamSummary: async ( + idOrSlug: string, + tournamentId?: GUID + ): Promise> => + await sendGet( + `${routes.teams}/${idOrSlug}/summary`, + tournamentId ? { tournamentId } : undefined + ), + + /** + * Retrieves a team's fixture (matches ordered by date ascending), oriented to + * this team, for the public team profile. + * @param {string} idOrSlug - The id or public slug of the team. + * @param {GUID} [tournamentId] - Scopes the fixture to a single tournament. + * @returns {Promise>} The server response. + */ + getTeamMatches: async ( + idOrSlug: string, + tournamentId?: GUID + ): Promise> => + await sendGet( + `${routes.teams}/${idOrSlug}/matches`, + tournamentId ? { tournamentId } : undefined + ), + + /** + * Retrieves the tournaments a team has taken part in (newest first), used to + * drive the team profile's season/tournament selector. + * @param {string} idOrSlug - The id or public slug of the team. + * @returns {Promise>} The server response. + */ + getTeamParticipations: async ( + idOrSlug: string + ): Promise> => + await sendGet(`${routes.teams}/${idOrSlug}/participations`), +}; diff --git a/Club12-WebClient/src/modules/team/type/team.d.ts b/Club12-WebClient/src/modules/team/type/team.d.ts new file mode 100644 index 0000000..d3fdf21 --- /dev/null +++ b/Club12-WebClient/src/modules/team/type/team.d.ts @@ -0,0 +1,304 @@ +import { + FetchOptions, + Filtered, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { IPublicPlayerResponse } from '@/modules/player/type/player.d'; +import { IScorerByPlayerResponse } from '@/modules/scorer/type/scorer.d'; + +/** + * Context properties and methods for managing teams in a sports system. + * These methods allow for creating, updating, fetching, and deleting teams. + * @interface ITeamContextProps + */ +export interface ITeamContextProps { + team: ITeamResponse | null; + teams: ITeamResponse[] | null; + /** + * Adds a new team. + * @param team The details of the team to add. + * @returns A promise that resolves with the response containing the newly added team. + */ + addTeam(team: IAddTeamRequest): Promise; + + /** + * Updates an existing team by its ID. + * @param id The ID of the team to update. + * @param data The updated team data. + * @returns A promise that resolves to whether the update succeeded (a PUT + * answers 204 with no body, so there is no updated entity to return). + */ + putTeamById(id: GUID, data: IPutTeamRequest): Promise; + + /** + * Updates the logo of an existing team. + * @param id The ID of the team whose logo is to be updated. + * @param logo The new logo file. + * @returns A promise that resolves when the logo is successfully updated. + */ + putTeamLogoById(id: GUID, logo: File): Promise; + + /** + * Fetches teams based on filters. + * @param filters The filters to apply when fetching the teams. + * @returns A promise that resolves with the paginated response containing teams that match the filters. + */ + getTeamsByFiltered( + filters: TeamFiltered, + options?: FetchOptions + ): Promise | void>; + + /** + * Fetches a team by its ID or its public slug. + * @param idOrSlug The ID or slug of the team to fetch. + * @param options Per-call options; `silent` suppresses the global alert on failure. + * @returns A promise that resolves with the team details. + */ + getTeamById( + idOrSlug: string, + options?: FetchOptions + ): Promise; + + /** + * Deletes a team by its ID. + * @param id The ID of the team to delete. + * @returns A promise resolving to `true` if the team was deleted, `false` + * if the request failed (the global error is already reported either way). + */ + deleteTeamById(id: GUID): Promise; +} + +/** + * The request body structure for adding a new team. + * @interface IAddTeamRequest + */ +export interface IAddTeamRequest { + /** + * The name of the team. + * @type {string} + */ + name: string; + + /** + * The three-letter code representing the team. + * @type {string} + */ + threeLetterCode: string; + + /** + * The shirt color of the team. + * @type {string} + */ + shirtColor: string; + + /** + * The secondary shirt color, used for the jersey pattern and trim. + * @type {string | null} + */ + shirtSecondaryColor?: string | null; + + /** + * The third shirt color, used only by tri-color jersey templates as a + * second accent alongside {@link shirtSecondaryColor}. + * @type {string | null} + */ + shirtTertiaryColor?: string | null; + + /** + * The selected jersey kit template (e.g. `solid`, `stripes`). + * @type {string | null} + */ + jerseyStyle?: string | null; + + /** + * The logo file of the team. + * @type {File} + */ + logo: File; + + /** + * The ID of the tournament the team belongs to. + * @type {GUID} + */ + tournamentId?: GUID; +} + +/** + * The response structure for a team. + * @interface ITeamResponse + */ +export interface ITeamResponse { + /** + * The unique ID of the team. + * @type {GUID} + */ + id: GUID; + + /** + * The name of the team. + * @type {string} + */ + name: string; + + /** + * The unique, URL-friendly identifier used in public team links. + * @type {string} + */ + slug: string; + + /** + * The three-letter code representing the team. + * @type {string} + */ + threeLetterCode: string; + + /** + * The shirt color of the team. + * @type {string} + */ + shirtColor: string; + + /** + * The secondary shirt color, used for the jersey pattern and trim. + * @type {string | null} + */ + shirtSecondaryColor?: string | null; + + /** + * The third shirt color, used only by tri-color jersey templates as a + * second accent alongside {@link shirtSecondaryColor}. + * @type {string | null} + */ + shirtTertiaryColor?: string | null; + + /** + * The selected jersey kit template (e.g. `solid`, `stripes`). + * @type {string | null} + */ + jerseyStyle?: string | null; + + /** + * The URL of the team's logo. + * @type {string} + */ + logoUrl: string; + + /** + * A list of players on the team. + * @type {IPublicPlayerResponse[]} + */ + players: IPublicPlayerResponse[]; + + tournamentId: GUID | null; + + /** + * The name of the team's current tournament (`tournamentId`), e.g. "Torneo + * Apertura Masculino 2025" — disambiguates same-named teams from different + * seasons in an "existing team" picker. Null when `tournamentId` is null. + */ + tournamentName: string | null; + + /** + * The stable cross-season club this team belongs to (HU-99), when linked. + * Absent/undefined until the team is associated with a club. + * @type {GUID | null} + */ + clubId?: GUID | null; +} + +/** + * The request body structure for updating an existing team. + * @interface IPutTeamRequest + */ +export interface IPutTeamRequest { + /** + * The updated name of the team. + * @type {string} + */ + name?: string; + + /** + * The updated three-letter code representing the team. + * @type {string} + */ + threeLetterCode?: string; + + /** + * The updated shirt color of the team. + * @type {string} + */ + shirtColor?: string; + + /** + * The updated secondary shirt color, used for the jersey pattern and trim. + * @type {string | null} + */ + shirtSecondaryColor?: string | null; + + /** + * The updated third shirt color, used only by tri-color jersey templates. + * @type {string | null} + */ + shirtTertiaryColor?: string | null; + + /** + * The updated jersey kit template (e.g. `solid`, `stripes`). + * @type {string | null} + */ + jerseyStyle?: string | null; +} + +/** + * @interface ITeamMatchResponse + * @description Represents the response structure for a team in a match, + * including its identification, visual details, and score. + */ +export interface ITeamMatchResponse { + /** + * @property {GUID} id - The unique identifier (GUID) for the team. + */ + id: GUID; + + /** + * @property {string} name - The name of the team. + */ + name: string; + + /** + * @property {string} logoUrl - The URL pointing to the team's logo image. + */ + logoUrl: string; + + /** Primary shirt color (#rrggbb), for rendering the kit on the scoreboard. */ + shirtColor?: string | null; + + /** Jersey kit pattern (e.g. "solid", "stripes"). */ + jerseyStyle?: string | null; + + /** Secondary shirt color (#rrggbb), for the kit trim/pattern. */ + shirtSecondaryColor?: string | null; + + /** Third shirt color (#rrggbb), used only by tri-color kit templates. */ + shirtTertiaryColor?: string | null; + + /** + * @property {number} score - The score achieved by the team in the match. + */ + score: number; + + players: IPublicPlayerResponse[]; + + /** + * @property {IScorerByPlayerResponse[]} scorers - The scorers for the team in the match. + */ + scorers: IScorerByPlayerResponse[]; +} +/** + * The filters for fetching teams. + * @interface TeamFiltered + */ +export interface TeamFiltered extends IPutTeamRequest, Filtered { + stageId?: GUID; + tournamentId?: GUID; +} diff --git a/Club12-WebClient/src/modules/team/type/teamProfile.d.ts b/Club12-WebClient/src/modules/team/type/teamProfile.d.ts new file mode 100644 index 0000000..53cf6fb --- /dev/null +++ b/Club12-WebClient/src/modules/team/type/teamProfile.d.ts @@ -0,0 +1,75 @@ +import { GUID } from '@/modules/core/types/types'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; + +/** + * The public "team profile" contract — the standing, fixture and participation + * data behind a team's public page. These mirror the backend projections served + * from `/api/teams/{idOrSlug}/…` and are read-only. + */ + +/** + * A team's standing within one division of one tournament. Served by + * `GET /api/teams/{idOrSlug}/summary?tournamentId=`; the endpoint answers `null` + * when the team has no standing yet (e.g. a fixture that has not started). + */ +export interface TeamSummary { + divisionId: GUID; + divisionName: string; + /** The team's 1-based position in its division's table. */ + position: number; + /** How many teams share the division (the "de N" in "3º de 8"). */ + totalTeams: number; + played: number; + wins: number; + losses: number; + pointsFor: number; + pointsAgainst: number; + /** `pointsFor - pointsAgainst`; may be negative. */ + pointsDifference: number; + /** Table points (the league's own scoring, not basketball points). */ + points: number; +} + +/** A finished team match's outcome from the team's own perspective. */ +export type TeamMatchResult = 'W' | 'L'; + +/** + * One match on a team's fixture, oriented to the team whose page is shown. + * Served (ordered by date ascending) by + * `GET /api/teams/{idOrSlug}/matches?tournamentId=`. + */ +export interface TeamMatch { + matchId: GUID; + /** ISO date-time, or `null` when the match is not scheduled yet. */ + matchDate: string | null; + isFinished: boolean; + /** Raw backend status string (kept opaque; used only for display). */ + status: string; + /** Whether this team plays at home. */ + isHome: boolean; + opponentTeamId: GUID; + opponentName: string; + opponentLogoUrl: string | null; + /** This team's score, or `null` until the match is finished. */ + teamScore: number | null; + opponentScore: number | null; + /** `'W'`/`'L'` for a finished match, `null` otherwise. */ + result: TeamMatchResult | null; + venueName: string | null; +} + +/** + * One tournament this team has taken part in. Served (newest first) by + * `GET /api/teams/{idOrSlug}/participations`. `isCurrent` marks the ongoing + * tournament, used as the default selection on the team page. + */ +export interface TeamParticipation { + tournamentId: GUID; + tournamentName: string; + tournamentSlug: string | null; + category: TournamentCategory; + seasonId: GUID | null; + seasonName: string | null; + year: number | null; + isCurrent: boolean; +} diff --git a/Club12-WebClient/src/modules/team/utils/teamProfile.test.ts b/Club12-WebClient/src/modules/team/utils/teamProfile.test.ts new file mode 100644 index 0000000..d6d4c49 --- /dev/null +++ b/Club12-WebClient/src/modules/team/utils/teamProfile.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import { GUID } from '@/modules/core/types/types'; +import { TeamMatch } from '@/modules/team/type/teamProfile.d'; +import { + computeRecord, + deriveStreak, + formatDifferential, + formatPosition, + formatRecord, + splitFixture, +} from '@/modules/team/utils/teamProfile'; + +const guid = (value: string) => value as GUID; + +const match = (overrides: Partial = {}): TeamMatch => ({ + matchId: guid('11111111-1111-1111-1111-111111111111'), + matchDate: '2025-01-01T20:00:00Z', + isFinished: true, + status: 'Finished', + isHome: true, + opponentTeamId: guid('22222222-2222-2222-2222-222222222222'), + opponentName: 'Rival', + opponentLogoUrl: null, + teamScore: 80, + opponentScore: 70, + result: 'W', + venueName: 'Gimnasio', + ...overrides, +}); + +describe('deriveStreak', () => { + it('returns only finished results, newest last, capped at 5', () => { + // 7 finished matches in ascending date order, alternating W/L, plus a + // future (unfinished) one that must be ignored. + const matches: TeamMatch[] = [ + match({ matchDate: '2025-01-01T00:00:00Z', result: 'W' }), + match({ matchDate: '2025-01-02T00:00:00Z', result: 'L' }), + match({ matchDate: '2025-01-03T00:00:00Z', result: 'W' }), + match({ matchDate: '2025-01-04T00:00:00Z', result: 'W' }), + match({ matchDate: '2025-01-05T00:00:00Z', result: 'L' }), + match({ matchDate: '2025-01-06T00:00:00Z', result: 'W' }), + match({ matchDate: '2025-01-07T00:00:00Z', result: 'L' }), + match({ + matchDate: '2025-02-01T00:00:00Z', + isFinished: false, + result: null, + teamScore: null, + opponentScore: null, + }), + ]; + + // Last 5 finished, chronological (oldest -> newest, left to right). + expect(deriveStreak(matches)).toEqual(['W', 'W', 'L', 'W', 'L']); + }); + + it('ignores finished matches that somehow lack a result', () => { + const matches: TeamMatch[] = [ + match({ result: 'W' }), + match({ isFinished: true, result: null }), + match({ result: 'L' }), + ]; + + expect(deriveStreak(matches)).toEqual(['W', 'L']); + }); + + it('returns an empty array when there are no finished matches', () => { + expect(deriveStreak([match({ isFinished: false, result: null })])).toEqual( + [] + ); + expect(deriveStreak([])).toEqual([]); + }); +}); + +describe('splitFixture', () => { + it('splits into upcoming (not finished, ascending) and recent (finished, newest first, capped at 5)', () => { + const upcomingA = match({ + matchId: guid('aaaaaaaa-0000-0000-0000-000000000001'), + matchDate: '2025-03-01T00:00:00Z', + isFinished: false, + result: null, + teamScore: null, + opponentScore: null, + }); + const upcomingB = match({ + matchId: guid('aaaaaaaa-0000-0000-0000-000000000002'), + matchDate: '2025-03-08T00:00:00Z', + isFinished: false, + result: null, + teamScore: null, + opponentScore: null, + }); + const finished = Array.from({ length: 6 }, (_, i) => + match({ + matchId: guid(`bbbbbbbb-0000-0000-0000-00000000000${i}`), + matchDate: `2025-01-0${i + 1}T00:00:00Z`, + result: i % 2 === 0 ? 'W' : 'L', + }) + ); + + // Input arrives date-ascending: finished first, then the two upcoming. + const { upcoming, recent } = splitFixture([...finished, upcomingA, upcomingB]); + + expect(upcoming.map(m => m.matchId)).toEqual([ + upcomingA.matchId, + upcomingB.matchId, + ]); + + // Recent is capped to the 5 most recent finished, newest first. + expect(recent).toHaveLength(5); + expect(recent[0].matchId).toBe(finished[5].matchId); + expect(recent[4].matchId).toBe(finished[1].matchId); + }); + + it('handles a team with only upcoming matches', () => { + const { upcoming, recent } = splitFixture([ + match({ isFinished: false, result: null }), + ]); + + expect(upcoming).toHaveLength(1); + expect(recent).toHaveLength(0); + }); +}); + +describe('computeRecord', () => { + it('aggregates wins, losses, played and points across all finished matches', () => { + const matches: TeamMatch[] = [ + match({ result: 'W', teamScore: 80, opponentScore: 70 }), + match({ result: 'W', teamScore: 90, opponentScore: 60 }), + match({ result: 'L', teamScore: 55, opponentScore: 65 }), + // Unfinished: ignored entirely. + match({ + isFinished: false, + result: null, + teamScore: null, + opponentScore: null, + }), + ]; + + expect(computeRecord(matches)).toEqual({ + wins: 2, + losses: 1, + played: 3, + pointsFor: 225, + pointsAgainst: 195, + pointsDifference: 30, + }); + }); + + it('counts a finished match with no score as played but adds no points', () => { + const matches: TeamMatch[] = [ + match({ result: 'W', teamScore: null, opponentScore: null }), + ]; + + expect(computeRecord(matches)).toEqual({ + wins: 1, + losses: 0, + played: 1, + pointsFor: 0, + pointsAgainst: 0, + pointsDifference: 0, + }); + }); + + it('is all zeros when there are no finished matches', () => { + expect(computeRecord([])).toEqual({ + wins: 0, + losses: 0, + played: 0, + pointsFor: 0, + pointsAgainst: 0, + pointsDifference: 0, + }); + }); +}); + +describe('formatRecord', () => { + it('joins wins and losses with a dash', () => { + expect(formatRecord(5, 2)).toBe('5-2'); + expect(formatRecord(0, 0)).toBe('0-0'); + }); +}); + +describe('formatPosition', () => { + it('appends the Spanish ordinal marker', () => { + expect(formatPosition(1)).toBe('1º'); + expect(formatPosition(3)).toBe('3º'); + }); +}); + +describe('formatDifferential', () => { + it('prefixes a plus sign for non-negative values and keeps the minus for negatives', () => { + expect(formatDifferential(12)).toBe('+12'); + expect(formatDifferential(0)).toBe('0'); + expect(formatDifferential(-5)).toBe('-5'); + }); +}); diff --git a/Club12-WebClient/src/modules/team/utils/teamProfile.ts b/Club12-WebClient/src/modules/team/utils/teamProfile.ts new file mode 100644 index 0000000..e2dbeba --- /dev/null +++ b/Club12-WebClient/src/modules/team/utils/teamProfile.ts @@ -0,0 +1,93 @@ +import { TeamMatch, TeamMatchResult } from '@/modules/team/type/teamProfile.d'; + +/** How many recent results the streak/"Últimos" blocks show. */ +const RECENT_LIMIT = 5; + +/** A finished match that actually carries a W/L result. */ +const isDecided = ( + match: TeamMatch +): match is TeamMatch & { result: TeamMatchResult } => + match.isFinished && match.result !== null; + +/** + * The team's last {@link RECENT_LIMIT} finished results, oldest first so the row + * reads left-to-right in time order (e.g. `['W','W','L','W','L']`). Input is + * expected date-ascending (the matches endpoint's order); unfinished matches and + * finished ones missing a result are ignored. + */ +export const deriveStreak = (matches: TeamMatch[]): TeamMatchResult[] => + matches + .filter(isDecided) + .slice(-RECENT_LIMIT) + .map(match => match.result); + +/** + * Splits a fixture into the two lists the team page renders: + * - `upcoming`: matches not yet finished, kept in ascending (nearest-first) order. + * - `recent`: the {@link RECENT_LIMIT} most recently finished matches, newest first. + * Input is expected date-ascending (the matches endpoint's order). + */ +export const splitFixture = ( + matches: TeamMatch[] +): { upcoming: TeamMatch[]; recent: TeamMatch[] } => { + const upcoming = matches.filter(match => !match.isFinished); + const recent = matches + .filter(match => match.isFinished) + .slice(-RECENT_LIMIT) + .reverse(); + + return { upcoming, recent }; +}; + +/** A team's aggregated season record across all of its finished matches. */ +export interface TeamRecord { + wins: number; + losses: number; + played: number; + pointsFor: number; + pointsAgainst: number; + pointsDifference: number; +} + +/** + * Aggregates a team's full record from ALL of its finished matches (group stage + * AND playoffs), not just the group-stage standing. This keeps the headline + * record, points-for/against and differential consistent with the streak and + * fixture the visitor sees right below them — a standing only counts group-stage + * games, so a team that also played playoffs would otherwise show fewer wins + * than it actually has. Matches missing a score are still counted as played but + * contribute no points. + */ +export const computeRecord = (matches: TeamMatch[]): TeamRecord => { + const finished = matches.filter(match => match.isFinished); + + return finished.reduce( + (record, match) => { + const pointsFor = record.pointsFor + (match.teamScore ?? 0); + const pointsAgainst = record.pointsAgainst + (match.opponentScore ?? 0); + return { + wins: record.wins + (match.result === 'W' ? 1 : 0), + losses: record.losses + (match.result === 'L' ? 1 : 0), + played: record.played + 1, + pointsFor, + pointsAgainst, + pointsDifference: pointsFor - pointsAgainst, + }; + }, + { wins: 0, losses: 0, played: 0, pointsFor: 0, pointsAgainst: 0, pointsDifference: 0 } + ); +}; + +/** Formats a win-loss record as `"5-2"`. */ +export const formatRecord = (wins: number, losses: number): string => + `${wins}-${losses}`; + +/** Formats a 1-based table position with the Spanish ordinal marker, e.g. `"3º"`. */ +export const formatPosition = (position: number): string => `${position}º`; + +/** + * Formats a points differential with an explicit sign: `"+12"`, `"0"`, `"-5"`. + * A non-negative differential reads as a positive (green) tone on the page. + */ +export const formatDifferential = (difference: number): string => + difference > 0 ? `+${difference}` : `${difference}`; diff --git a/Club12-WebClient/src/modules/teamStaff/hook/teamStaff.hook.test.ts b/Club12-WebClient/src/modules/teamStaff/hook/teamStaff.hook.test.ts new file mode 100644 index 0000000..9ec635e --- /dev/null +++ b/Club12-WebClient/src/modules/teamStaff/hook/teamStaff.hook.test.ts @@ -0,0 +1,114 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { useTeamStaff } from '@/modules/teamStaff/hook/teamStaff.hook'; +import { teamStaffService } from '@/modules/teamStaff/service/teamStaff.service'; +import { ITeamStaffResponse } from '@/modules/teamStaff/type/teamStaff'; +import type { GUID } from '@/modules/core/types/types'; + +vi.mock('@/modules/teamStaff/service/teamStaff.service'); + +const mocked = vi.mocked(teamStaffService); +const TEAM_ID = 'team-1' as unknown as GUID; +const TOURNAMENT_ID = 'tournament-1' as unknown as GUID; + +const buildStaff = ( + overrides: Partial +): ITeamStaffResponse => ({ + id: 'staff-1' as unknown as GUID, + teamId: TEAM_ID, + teamName: 'Aguará', + tournamentId: TOURNAMENT_ID, + fullName: 'Juan Pérez', + role: 'Coach', + dateCreated: '2026-01-01T00:00:00Z', + ...overrides, +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const asAxios = (data: T) => ({ data }) as any; + +describe('useTeamStaff', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('loads the team staff on mount', async () => { + mocked.getTeamStaffByTeamId.mockResolvedValue(asAxios([buildStaff({})])); + + const { result } = renderHook(() => useTeamStaff(TEAM_ID, TOURNAMENT_ID)); + + await waitFor(() => expect(result.current.staff).toHaveLength(1)); + expect(mocked.getTeamStaffByTeamId).toHaveBeenCalledWith( + TEAM_ID, + TOURNAMENT_ID + ); + }); + + it('stays idle without a team id', async () => { + const { result } = renderHook(() => + useTeamStaff(undefined, TOURNAMENT_ID) + ); + + await act(async () => { + await result.current.refresh(); + }); + + expect(mocked.getTeamStaffByTeamId).not.toHaveBeenCalled(); + expect(result.current.staff).toEqual([]); + }); + + it('stays idle without a tournament id', async () => { + const { result } = renderHook(() => useTeamStaff(TEAM_ID, undefined)); + + await act(async () => { + await result.current.refresh(); + }); + + expect(mocked.getTeamStaffByTeamId).not.toHaveBeenCalled(); + expect(result.current.staff).toEqual([]); + }); + + it('creates a staff member then refreshes the list', async () => { + mocked.getTeamStaffByTeamId + .mockResolvedValueOnce(asAxios([])) + .mockResolvedValueOnce(asAxios([buildStaff({})])); + mocked.addTeamStaff.mockResolvedValue(asAxios(buildStaff({}))); + + const { result } = renderHook(() => useTeamStaff(TEAM_ID, TOURNAMENT_ID)); + await waitFor(() => + expect(mocked.getTeamStaffByTeamId).toHaveBeenCalledTimes(1) + ); + + await act(async () => { + await result.current.create({ + fullName: 'Juan Pérez', + role: 'Coach', + tournamentId: TOURNAMENT_ID, + }); + }); + + expect(mocked.addTeamStaff).toHaveBeenCalledWith(TEAM_ID, { + fullName: 'Juan Pérez', + role: 'Coach', + tournamentId: TOURNAMENT_ID, + }); + await waitFor(() => expect(result.current.staff).toHaveLength(1)); + }); + + it('removes a staff member then refreshes the list', async () => { + mocked.getTeamStaffByTeamId + .mockResolvedValueOnce(asAxios([buildStaff({})])) + .mockResolvedValueOnce(asAxios([])); + mocked.deleteTeamStaff.mockResolvedValue(asAxios(undefined)); + + const { result } = renderHook(() => useTeamStaff(TEAM_ID, TOURNAMENT_ID)); + await waitFor(() => expect(result.current.staff).toHaveLength(1)); + + await act(async () => { + await result.current.remove('staff-1' as unknown as GUID); + }); + + expect(mocked.deleteTeamStaff).toHaveBeenCalledWith('staff-1'); + await waitFor(() => expect(result.current.staff).toHaveLength(0)); + }); +}); diff --git a/Club12-WebClient/src/modules/teamStaff/hook/teamStaff.hook.ts b/Club12-WebClient/src/modules/teamStaff/hook/teamStaff.hook.ts new file mode 100644 index 0000000..28f590e --- /dev/null +++ b/Club12-WebClient/src/modules/teamStaff/hook/teamStaff.hook.ts @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useState } from 'react'; +import { GUID } from '@/modules/core/types/types'; +import { teamStaffService } from '@/modules/teamStaff/service/teamStaff.service'; +import { + ICreateTeamStaffRequest, + ITeamStaffResponse, +} from '@/modules/teamStaff/type/teamStaff'; + +/** + * The shape returned by {@link useTeamStaff}. + */ +export interface UseTeamStaff { + /** The team's technical staff for the given tournament. */ + staff: ITeamStaffResponse[]; + /** Whether a list refresh is in flight. */ + loading: boolean; + /** Reloads the team's staff from the server. */ + refresh: () => Promise; + /** Adds a new staff member and refreshes the list. Returns the created row. */ + create: ( + request: ICreateTeamStaffRequest + ) => Promise; + /** Removes a staff member by id and refreshes the list. */ + remove: (id: GUID) => Promise; +} + +/** + * Manages the technical staff (cuerpo técnico) of a single team, scoped to a + * tournament (season) participation: loads the list, and creates/removes + * entries. Standalone (no provider needed) so it can be dropped into both the + * admin team page and the public team profile. Pass a falsy `teamId` or + * `tournamentId` to keep it idle until both have resolved. + * @param teamId - The team whose staff to manage. + * @param tournamentId - The tournament (season participation) to scope by. + */ +export const useTeamStaff = ( + teamId: GUID | undefined, + tournamentId: GUID | undefined +): UseTeamStaff => { + const [staff, setStaff] = useState([]); + const [loading, setLoading] = useState(false); + + const refresh = useCallback(async () => { + if (!teamId || !tournamentId) { + return; + } + setLoading(true); + try { + const response = await teamStaffService.getTeamStaffByTeamId( + teamId, + tournamentId + ); + setStaff(response.data ?? []); + } finally { + setLoading(false); + } + }, [teamId, tournamentId]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const create = useCallback( + async (request: ICreateTeamStaffRequest): Promise => { + if (!teamId) { + throw new Error('A team is required to add technical staff.'); + } + const response = await teamStaffService.addTeamStaff(teamId, request); + await refresh(); + return response.data; + }, + [teamId, refresh] + ); + + const remove = useCallback( + async (id: GUID): Promise => { + await teamStaffService.deleteTeamStaff(id); + await refresh(); + }, + [refresh] + ); + + return { staff, loading, refresh, create, remove }; +}; diff --git a/Club12-WebClient/src/modules/teamStaff/service/teamStaff.service.ts b/Club12-WebClient/src/modules/teamStaff/service/teamStaff.service.ts new file mode 100644 index 0000000..293b42b --- /dev/null +++ b/Club12-WebClient/src/modules/teamStaff/service/teamStaff.service.ts @@ -0,0 +1,52 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { GUID } from '@/modules/core/types/types'; +import { sendDelete, sendGet, sendPost } from '@/modules/core/utils/axiosUtils'; +import { + ICreateTeamStaffRequest, + ITeamStaffResponse, +} from '@/modules/teamStaff/type/teamStaff'; + +/** + * Service for the team technical-staff (cuerpo técnico) endpoints. Adding and + * deleting require AdminOrOwner; listing is public. + */ +export const teamStaffService = { + /** + * Adds a technical staff member to a team for a given tournament. + * @param {GUID} teamId - The team the staff member belongs to. + * @param {ICreateTeamStaffRequest} request - The name, role and tournament. + * @returns {Promise>} The created staff member. + */ + addTeamStaff: async ( + teamId: GUID, + request: ICreateTeamStaffRequest + ): Promise> => + sendPost( + `${routes.teams}/${teamId}/${routes.staff}`, + request + ), + + /** + * Lists a team's technical staff for a given tournament. + * @param {GUID} teamId - The team whose staff to list. + * @param {GUID} tournamentId - The tournament (season participation) to scope by. + * @returns {Promise>} The staff members. + */ + getTeamStaffByTeamId: async ( + teamId: GUID, + tournamentId: GUID + ): Promise> => + sendGet( + `${routes.teams}/${teamId}/${routes.staff}`, + { tournamentId } + ), + + /** + * Removes a technical staff member by its id. + * @param {GUID} id - The id of the staff member to remove. + * @returns {Promise>} The server response. + */ + deleteTeamStaff: async (id: GUID): Promise> => + sendDelete(`${routes.staff}/${id}`), +}; diff --git a/Club12-WebClient/src/modules/teamStaff/type/teamStaff.d.ts b/Club12-WebClient/src/modules/teamStaff/type/teamStaff.d.ts new file mode 100644 index 0000000..c55c2e5 --- /dev/null +++ b/Club12-WebClient/src/modules/teamStaff/type/teamStaff.d.ts @@ -0,0 +1,53 @@ +import { GUID } from '@/modules/core/types/types'; + +/** + * The role a technical staff member holds for a team's participation in a + * tournament. Mirrors the backend `TeamStaffRole` enum. + */ +export type TeamStaffRole = 'Coach' | 'AssistantCoach'; + +/** + * A team's technical staff (cuerpo técnico) member, as returned by the create + * and list endpoints. Mirrors the backend `TeamStaffResponse`. Scoped to a + * single team+tournament participation (season-scoped). + * @interface ITeamStaffResponse + */ +export interface ITeamStaffResponse { + /** The unique identifier of the staff member. */ + id: GUID; + + /** The team this staff member belongs to. */ + teamId: GUID; + + /** The team's name, when the team was loaded. */ + teamName?: string; + + /** The tournament (season participation) this staff member is scoped to. */ + tournamentId: GUID; + + /** The staff member's full name. */ + fullName: string; + + /** The staff member's role (DT, Asistente). */ + role: TeamStaffRole; + + /** When the staff member was registered (ISO date). */ + dateCreated: string; +} + +/** + * The request body for adding a technical staff member to a team. The team is + * taken from the route, not the body. Mirrors the backend + * `CreateTeamStaffRequest`. + * @interface ICreateTeamStaffRequest + */ +export interface ICreateTeamStaffRequest { + /** The staff member's full name. */ + fullName: string; + + /** The staff member's role. */ + role: TeamStaffRole; + + /** The tournament (season participation) to scope this staff member to. */ + tournamentId: GUID; +} diff --git a/Club12-WebClient/src/modules/teamStaff/utils/teamStaffDisplay.ts b/Club12-WebClient/src/modules/teamStaff/utils/teamStaffDisplay.ts new file mode 100644 index 0000000..3b22d1e --- /dev/null +++ b/Club12-WebClient/src/modules/teamStaff/utils/teamStaffDisplay.ts @@ -0,0 +1,7 @@ +import { TeamStaffRole } from '@/modules/teamStaff/type/teamStaff'; + +/** The Spanish label shown for each technical-staff role. */ +export const TEAM_STAFF_ROLE_LABEL: Record = { + Coach: 'DT', + AssistantCoach: 'Asistente', +}; diff --git a/Club12-WebClient/src/modules/tournament/context/tournament.context.test.tsx b/Club12-WebClient/src/modules/tournament/context/tournament.context.test.tsx new file mode 100644 index 0000000..bc94acb --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/context/tournament.context.test.tsx @@ -0,0 +1,127 @@ +import { act, renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import Swal from 'sweetalert2'; +import { ErrorProvider } from '@/modules/error/context/error.context'; +import { TournamentProvider } from '@/modules/tournament/context/tournament.context'; +import { useTournament } from '@/modules/tournament/hook/tournament.hook'; +import { tournamentService } from '@/modules/tournament/service/tournament.service'; +import type { ITournamentResponse } from '@/modules/tournament/type/tournament.d'; +import type { GUID } from '@/modules/core/types/types'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; + +vi.mock('@/modules/tournament/service/tournament.service'); +vi.mock('sweetalert2', () => ({ + default: { + fire: vi.fn(), + getContainer: vi.fn().mockReturnValue(null), + }, +})); + +const mockedGetAllTournamentsByFilter = vi.mocked( + tournamentService.getAllTournamentsByFilter +); +const mockedCreateFullTournament = vi.mocked( + tournamentService.createFullTournament +); +const mockedAddFullDivision = vi.mocked(tournamentService.addFullDivision); +const mockedSwalFire = vi.mocked(Swal.fire); + +const TOURNAMENT_ID = 'guid-a-aaaa-bbbb-cccc' as unknown as GUID; + +const buildTournament = ( + overrides: Partial = {} +): ITournamentResponse => ({ + id: 'guid-a-aaaa-bbbb-cccc' as unknown as GUID, + description: 'Torneo de prueba', + name: 'Apertura', + slug: 'apertura', + divisions: [], + teamRegistrationDeadline: new Date('2026-01-01'), + startDate: new Date('2026-02-01'), + status: 'Scheduled', + category: TournamentCategory.Masculine, + seasonId: null, + seasonName: null, + ...overrides, +}); + +const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('TournamentProvider — getAllTournamentsByFilter dedup guard', () => { + /** + * Each mocked call resolves a brand-new array reference (same ids/data), + * so this test can only pass if the dedup guard's id-comparison genuinely + * skips `setState` — not because React bails out on an identical object + * reference on its own. The guard must also read the current `tournaments` + * state (not a stale, mount-time closure) so it skips `setState` when the + * fetched ids are unchanged on the second call. + */ + it('keeps the tournaments reference stable when a repeated filter fetch returns the same items', async () => { + mockedGetAllTournamentsByFilter.mockResolvedValueOnce({ + data: { items: [buildTournament()], page: 1, pageSize: 100, totalCount: 1 }, + } as never); + mockedGetAllTournamentsByFilter.mockResolvedValueOnce({ + data: { items: [buildTournament()], page: 1, pageSize: 100, totalCount: 1 }, + } as never); + + const { result } = renderHook(() => useTournament(), { wrapper }); + + await act(async () => { + await result.current.getAllTournamentsByFilter({}); + }); + + const firstReference = result.current.tournaments; + expect(firstReference).not.toBeNull(); + + await act(async () => { + await result.current.getAllTournamentsByFilter({}); + }); + + expect(result.current.tournaments).toBe(firstReference); + }); +}); + +describe('TournamentProvider — no duplicate success toast', () => { + /** + * The wizard (TournamentWizardPage) and divisionCreatePage each show their + * own confirmation for these actions. The context used to ALSO fire a + * toast — an empty one for createFullTournament / addFullDivision — so + * the user saw two modals for one action. + */ + it('does not fire its own toast after createFullTournament succeeds', async () => { + mockedCreateFullTournament.mockResolvedValueOnce({ + status: 200, + data: buildTournament(), + } as never); + + const { result } = renderHook(() => useTournament(), { wrapper }); + await act(async () => { + await result.current.createFullTournament({} as never); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('does not fire its own toast after addFullDivision succeeds', async () => { + mockedAddFullDivision.mockResolvedValueOnce({ + status: 200, + data: { id: 'div-1' }, + } as never); + + const { result } = renderHook(() => useTournament(), { wrapper }); + await act(async () => { + await result.current.addFullDivision(TOURNAMENT_ID, {} as never); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); +}); diff --git a/Club12-WebClient/src/modules/tournament/context/tournament.context.tsx b/Club12-WebClient/src/modules/tournament/context/tournament.context.tsx new file mode 100644 index 0000000..cd074bf --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/context/tournament.context.tsx @@ -0,0 +1,375 @@ +import { AxiosError, AxiosResponse } from 'axios'; +import { + createContext, + useEffect, + useState, + useCallback, + useMemo, +} from 'react'; +import { + FetchOptions, + GenericResponsePagination, + GUID, + ProviderProps, +} from '@/modules/core/types/types'; +import { useError } from '@/modules/error/hooks/error.hock'; +import { tournamentService } from '@/modules/tournament/service/tournament.service'; +import { + IAddTournamentRequest, + IEnrollTeamRequest, + ITournamentCompletability, + ITournamentContextProps, + IPutTournamentRequest, + ITournamentFiltered, + ITournamentResponse, + ITournamentStructureResponse, +} from '@/modules/tournament/type/tournament'; +import { + ICreateFullDivisionRequest, + ICreateFullTournamentRequest, +} from '@/modules/tournament/type/createFullTournament.d'; +import { IDivisionResponse } from '@/modules/division/type/division.d'; +import { upsertListById } from '@/modules/core/utils/synchronizeStates'; +import { ERROR_MESSAGES } from '@/modules/core/constants/constants'; +import { fetchAndSetList } from '@/modules/core/utils/comparator'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; + +export const TournamentContext = createContext< + ITournamentContextProps | undefined +>(undefined); + +export const TournamentProvider: React.FC = ({ children }) => { + const [tournament, setTournament] = useState( + null + ); + + const [tournaments, setTournaments] = useState( + null + ); + + const { setError, setMessage } = useError(); + + useEffect(() => { + if (!tournament) return; + + setTournaments(prev => upsertListById(prev, tournament)); + }, [tournament]); + + const addTournament = useCallback( + async ( + tournamentRequest: IAddTournamentRequest + ): Promise => { + try { + const res: AxiosResponse = + await tournamentService.addTournament(tournamentRequest); + + if (res && res.data) { + setTournament(res.data); + setMessage(res.status, []); + } + return res.data; + } catch (error: unknown) { + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + } + }, + [setTournament, setError, setMessage] + ); + + const createFullTournament = useCallback( + async ( + request: ICreateFullTournamentRequest + ): Promise => { + try { + const res: AxiosResponse = + await tournamentService.createFullTournament(request); + + if (res && res.data) { + setTournament(res.data); + } + return res.data; + } catch (error: unknown) { + // Re-thrown (unlike this file's other catch blocks) so submitWizard.ts can + // show the real backend rejection reason in its own persistent dialog, + // instead of only the global toast this setError call already fires. + if (error instanceof AxiosError) { + setError(error); + throw error; + } else { + const wrapped = new AxiosError(ERROR_MESSAGES.GENERIC_ERROR); + setError(wrapped); + throw wrapped; + } + } + }, + [setTournament, setError] + ); + + const addFullDivision = useCallback( + async ( + tournamentId: GUID, + request: ICreateFullDivisionRequest + ): Promise => { + try { + const res: AxiosResponse = + await tournamentService.addFullDivision(tournamentId, request); + + return res.data; + } catch (error: unknown) { + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + } + }, + [setError] + ); + + const putTournamentById = useCallback( + async ( + id: GUID, + tournamentRequest: IPutTournamentRequest + ): Promise => { + try { + const res: AxiosResponse = + await tournamentService.putTournamentById(id, tournamentRequest); + if (res && res.status === HttpStatus.NoContent) { + setTournament(prev => { + const fallbackFromList = + tournaments?.find(e => e.id === id) ?? null; + const current = prev && prev.id === id ? prev : fallbackFromList; + + if (!current) { + return prev; + } + + return { + ...current, + ...tournamentRequest, + status: tournamentRequest.status ?? current.status, + }; + }); + setMessage(res.status, ['Torneo actualizado correctamente']); + return true; + } + } catch (error: unknown) { + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + } + }, + [setTournament, setError, setMessage, tournaments] + ); + + const getTournamentById = useCallback( + async ( + id: string, + options?: FetchOptions + ): Promise => { + try { + const existTournament = tournaments?.find( + e => e.id === id || e.slug === id + ); + + if (existTournament) { + setTournament(existTournament); + return existTournament; + } + + const res: AxiosResponse = + await tournamentService.getTournamentById(id); + + if (res && res.data) { + setTournament(res.data); + return res.data; + } + } catch (error: unknown) { + if (options?.silent) return; + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + } + }, + [tournaments, setTournament, setError] + ); + + const getAllTournamentsByFilter = useCallback( + async ( + filter: ITournamentFiltered, + options?: FetchOptions + ): Promise | void> => { + try { + return await fetchAndSetList({ + apiCall: f => tournamentService.getAllTournamentsByFilter(f), + currentState: tournaments, + setState: setTournaments, + filter: filter, + }); + } catch (error: unknown) { + if (options?.silent) return; + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + } + }, + [setTournaments, setError, tournaments] + ); + + const deleteTournamentById = useCallback( + async (id: GUID): Promise => { + try { + await tournamentService.deleteTournamentById(id); + setTournament(null); + setTournaments(prev => (prev ? prev.filter(e => e.id !== id) : null)); + return true; + } catch (error: unknown) { + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + return false; + } + }, + [setTournament, setTournaments, setError] + ); + + const enrollTeam = useCallback( + async (id: GUID, request: IEnrollTeamRequest): Promise => { + try { + const res: AxiosResponse = await tournamentService.enrollTeam( + id, + request + ); + + if (res) { + setMessage(res.status, ['Equipo inscripto correctamente']); + } + return ( + res.status === HttpStatus.Ok || + res.status === HttpStatus.Created || + res.status === HttpStatus.NoContent + ); + } catch (error: unknown) { + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + } + }, + [setError, setMessage] + ); + + const unenrollTeam = useCallback( + async (id: GUID, teamId: GUID): Promise => { + try { + const res: AxiosResponse = await tournamentService.unenrollTeam( + id, + teamId + ); + + if (res) { + setMessage(res.status, ['Equipo dado de baja correctamente']); + } + return ( + res.status === HttpStatus.Ok || res.status === HttpStatus.NoContent + ); + } catch (error: unknown) { + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + } + }, + [setError, setMessage] + ); + + const getCompletability = useCallback( + async (id: GUID): Promise => { + try { + const res: AxiosResponse = + await tournamentService.getCompletability(id); + + if (res && res.data) { + return res.data; + } + } catch (error: unknown) { + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + } + }, + [setError] + ); + + const getStructure = useCallback( + async (idOrSlug: string): Promise => { + try { + const res: AxiosResponse = + await tournamentService.getStructure(idOrSlug); + + return res.data; + } catch (error: unknown) { + if (error instanceof AxiosError) { + setError(error); + } else { + setError(new AxiosError(ERROR_MESSAGES.GENERIC_ERROR)); + } + } + }, + [setError] + ); + + const container: ITournamentContextProps = useMemo( + () => ({ + tournament, + tournaments, + addTournament, + createFullTournament, + addFullDivision, + getAllTournamentsByFilter, + getTournamentById, + putTournamentById, + deleteTournamentById, + enrollTeam, + unenrollTeam, + getCompletability, + getStructure, + }), + [ + tournament, + tournaments, + addTournament, + createFullTournament, + addFullDivision, + getAllTournamentsByFilter, + getTournamentById, + putTournamentById, + deleteTournamentById, + enrollTeam, + unenrollTeam, + getCompletability, + getStructure, + ] + ); + + return ( + + {children} + + ); +}; diff --git a/Club12-WebClient/src/modules/tournament/hook/tournament.hook.ts b/Club12-WebClient/src/modules/tournament/hook/tournament.hook.ts new file mode 100644 index 0000000..1aaf299 --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/hook/tournament.hook.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react'; +import { TournamentContext } from '@/modules/tournament/context/tournament.context'; + +export const useTournament = () => { + const context = useContext(TournamentContext); + if (!context) { + throw new Error( + 'useTournament must be used whithin an Tournament Provider' + ); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/tournament/service/tournament.service.ts b/Club12-WebClient/src/modules/tournament/service/tournament.service.ts new file mode 100644 index 0000000..fad2678 --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/service/tournament.service.ts @@ -0,0 +1,172 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + IAddTournamentRequest, + IEnrollTeamRequest, + IPutTournamentRequest, + ITournamentCompletability, + ITournamentFiltered, + ITournamentResponse, + ITournamentStructureResponse, +} from '@/modules/tournament/type/tournament.d'; +import { + ICreateFullDivisionRequest, + ICreateFullTournamentRequest, +} from '@/modules/tournament/type/createFullTournament.d'; +import { IDivisionResponse } from '@/modules/division/type/division.d'; + +/** + * Service for managing tournaments. + */ +export const tournamentService = { + /** + * Adds a new tournament. + * @param {IAddTournamentRequest} tournament - The tournament details to add. + * @returns {Promise>} The server response. + */ + addTournament: async ( + tournament: IAddTournamentRequest + ): Promise> => + await sendPost(`${routes.tournaments}`, tournament), + + /** + * HU-38: creates a WHOLE tournament — base fields plus every division + * (zone/cross-cup) with its points, cups, playoff mappings and stages — in a + * single atomic transaction via `POST /api/tournaments/full`. A failure at any + * step persists nothing (all-or-nothing), and the backend creates the + * tournament already `OpenForRegistration`, so no separate open-registration + * call is needed. + * @param {ICreateFullTournamentRequest} request - The full tournament-wizard payload. + * @returns {Promise>} The created tournament (with its divisions). + */ + createFullTournament: async ( + request: ICreateFullTournamentRequest + ): Promise> => + await sendPost(`${routes.tournaments}/full`, request), + + /** + * HU-31/HU-112: adds ONE division (group stage + cups + playoff mappings) to + * an already-existing tournament in a single atomic transaction via + * `POST /api/tournaments/{tournamentId}/divisions/full` — the same structure + * guarantee a wizard-created division gets, instead of the bare division the + * granular `POST /api/divisions` endpoint leaves behind (no stages, no cups). + * @param {GUID} tournamentId - The parent tournament's id. + * @param {ICreateFullDivisionRequest} request - The division's structure (zone or cross-cup). + * @returns {Promise>} The created division. + */ + addFullDivision: async ( + tournamentId: GUID, + request: ICreateFullDivisionRequest + ): Promise> => + await sendPost(`${routes.tournaments}/${tournamentId}/divisions/full`, request), + + /** + * Updates an existing tournament. + * @param {string} id - The ID of the tournament to update. + * @param {IPutTournamentRequest} tournament - The updated tournament details. + * @returns {Promise>} The server response. + */ + putTournamentById: async ( + id: GUID, + tournament: IPutTournamentRequest + ): Promise> => + await sendPut(`${routes.tournaments}/${id}`, tournament), + + /** + * Retrieves a tournament by its ID or its public slug. + * @param {string} idOrSlug - The ID or slug of the tournament to retrieve. + * @returns {Promise>} The server response containing the tournament details. + */ + getTournamentById: async ( + idOrSlug: string + ): Promise> => + await sendGet(`${routes.tournaments}/${idOrSlug}`), + + /** + * Retrieves tournaments based on the provided filters. + * @param {ITournamentFiltered} filter - The filters to apply when retrieving tournaments. + * @returns {Promise>>} The server response containing the filtered tournaments. + */ + getAllTournamentsByFilter: async ( + filter: ITournamentFiltered + ): Promise>> => + await sendGet(routes.tournaments, withTablePageSize(filter)), + + /** + * Deletes a tournament by its ID. + * @param {string} id - The ID of the tournament to delete. + * @returns {Promise>} The server response. + */ + deleteTournamentById: async (id: GUID): Promise> => + await sendDelete(`${routes.tournaments}/${id}`), + + /** + * Enrolls a single team into a tournament during its registration phase + * (HU-107). Sends the enrollment contract to + * `POST /api/tournaments/{id}/enroll-team`. + * + * @async + * @function enrollTeam + * @param {GUID} id - The tournament identifier (GUID) to enroll the team into. + * @param {IEnrollTeamRequest} request - Enrollment payload. Exactly one of + * `existingTeamId` / `newTeamName` must be set; `copyRosterFromTournamentId` + * optionally seeds the roster from a previous season. + * @returns {Promise>} The server response. + */ + enrollTeam: async ( + id: GUID, + request: IEnrollTeamRequest + ): Promise> => + await sendPost(`${routes.tournaments}/${id}/enroll-team`, request), + + /** + * Removes a team's enrollment from a tournament (HU-108). Sends + * `DELETE /api/tournaments/{id}/teams/{teamId}`. The backend answers 204 on + * success and 409 once the tournament has started. + * + * @async + * @function unenrollTeam + * @param {GUID} id - The tournament identifier (GUID). + * @param {GUID} teamId - The team identifier (GUID) to unenroll. + * @returns {Promise>} The server response. + */ + unenrollTeam: async ( + id: GUID, + teamId: GUID + ): Promise> => + await sendDelete(`${routes.tournaments}/${id}/teams/${teamId}`), + + /** + * Fetches the live completability report for a tournament (HU-109) from + * `GET /api/tournaments/{id}/completability`. + * + * @async + * @function getCompletability + * @param {GUID} id - The tournament identifier (GUID). + * @returns {Promise>} The server response. + */ + getCompletability: async ( + id: GUID + ): Promise> => + await sendGet(`${routes.tournaments}/${id}/completability`), + + /** + * HU-cloning: fetches a source tournament's full cloneable structure tree + * from `GET /api/tournaments/{idOrSlug}/structure`, for pre-filling the + * creation wizard. Carries no instance data (teams, matches, rosters). + * @param {string} idOrSlug - The source tournament's ID or public slug. + * @returns {Promise>} The server response. + */ + getStructure: async ( + idOrSlug: string + ): Promise> => + await sendGet(`${routes.tournaments}/${idOrSlug}/structure`), +}; diff --git a/Club12-WebClient/src/modules/tournament/type/createFullTournament.d.ts b/Club12-WebClient/src/modules/tournament/type/createFullTournament.d.ts new file mode 100644 index 0000000..e27e3c9 --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/type/createFullTournament.d.ts @@ -0,0 +1,126 @@ +import { GUID } from '@/modules/core/types/types'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; +import { StageType } from '@/modules/stage/type/stage'; +import { PlayoffMappingRequest } from '@/modules/division/type/division.d'; + +/** + * HU-38: one stage within a {@link ICreateFullDivisionRequest}. Mirrors the + * backend `CreateFullStageRequest` DTO (the granular add-stage payload minus + * the DivisionId, which is implied by nesting). + * @interface ICreateFullStageRequest + */ +export interface ICreateFullStageRequest { + /** The name of the stage (e.g. "Fase de Grupos", "Copa de Oro - Final"). */ + name: string; + + /** Optional description providing additional details about the stage. */ + description?: string | null; + + /** The type of the stage (Group / RoundOf16 / QuarterFinal / …). */ + stageType: StageType; + + /** Defaults to true server-side when omitted. */ + isActive?: boolean | null; + + /** Defaults to `(stageType !== Group)` server-side when omitted. */ + isElimination?: boolean | null; + + /** The starting date of the stage. */ + startDate: Date; + + /** The ending date of the stage. */ + endDate: Date; + + /** + * Groups parallel elimination brackets under a cup name (e.g. "Copa de Oro"). + * Omit for the division's single/default bracket. + */ + bracketName?: string | null; + + /** Games in a series for this round (1, 3, 5 or 7). Defaults to 1. */ + bestOf?: number; + + /** Round-robin legs for a Group stage (1 = single, 2 = double, …). Defaults to 1. */ + roundRobinLegs?: number; +} + +/** + * HU-38: one division (zone or cross-division cup) within a + * {@link ICreateFullTournamentRequest}. The TournamentId is implied by nesting. + * Mirrors the backend `CreateFullDivisionRequest` DTO. + * @interface ICreateFullDivisionRequest + */ +export interface ICreateFullDivisionRequest { + /** The name of the division/zone. */ + name: string; + + /** Marks a cross-division cup that draws teams from every zone. Defaults to false. */ + isCrossDivisionCup?: boolean; + + /** Points awarded for a win in this division's standings (HU-79). Defaults to 2. */ + pointsForWin?: number; + + /** Points awarded for a loss in this division's standings (HU-79). Defaults to 1. */ + pointsForLoss?: number; + + /** + * For a cross-division cup (HU-110): how many teams advance from EACH internal + * group into the pooled knockout bracket. Only meaningful when + * `isCrossDivisionCup` is true. Defaults to 1. + */ + qualifiersPerGroup?: number; + + /** + * Competitive category (gender) of the division (HU-48). MUST match the parent + * tournament's category — a mismatch aborts the whole atomic create. + */ + category?: TournamentCategory; + + /** Optional position-range → playoff-destination mappings (HU-45). */ + playoffMappings?: PlayoffMappingRequest[]; + + /** The stages (group + cup elimination rounds) to create in this division. */ + stages: ICreateFullStageRequest[]; +} + +/** + * HU-38: the whole tournament-wizard payload persisted atomically by + * `POST /api/tournaments/full`. Carries the tournament base fields plus every + * division (zone/cross-cup) with its cups, points, playoff mappings and stages. + * The entire graph is created in a single transaction, so a failure leaves NO + * partial tournament behind — and the backend creates it already + * `OpenForRegistration`, so no separate open-registration call is needed. + * Mirrors the backend `CreateFullTournamentRequest` DTO. + * @interface ICreateFullTournamentRequest + */ +export interface ICreateFullTournamentRequest { + /** The name of the tournament. */ + name: string; + + /** A description of the tournament. */ + description: string; + + /** The deadline for team registrations. Must be earlier than the start date. */ + teamRegistrationDeadline: Date; + + /** The start date of the tournament. */ + startDate: Date; + + /** + * Competitive category (gender) of the tournament (HU-48). Every division + * below must share this category. Omit to let the backend default to Masculine. + */ + category?: TournamentCategory; + + /** + * Optional season ("Temporada") the tournament is grouped under. Omit/`null` + * to leave the tournament without a season. + */ + seasonId?: GUID | null; + + /** + * The divisions (zones and optional cross-division cup) to create under this + * tournament, each with its own stages and playoff mappings. + */ + divisions: ICreateFullDivisionRequest[]; +} diff --git a/Club12-WebClient/src/modules/tournament/type/tournament.d.ts b/Club12-WebClient/src/modules/tournament/type/tournament.d.ts new file mode 100644 index 0000000..b6787ee --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/type/tournament.d.ts @@ -0,0 +1,483 @@ +import { TournamentStatus } from '@/modules/core/enum/tournament/tournamentStatus'; +import { TournamentCategory } from '@/modules/core/enum/tournament/tournamentCategory'; +import { + FetchOptions, + Filtered, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; +import { + IDivisionResponse, + IMinimalDivisionResponse, +} from '@/modules/division/type/division'; +import { + ICreateFullDivisionRequest, + ICreateFullTournamentRequest, +} from '@/modules/tournament/type/createFullTournament.d'; +import { StageType } from '@/modules/stage/type/stage'; + +/** + * Context properties and methods for managing tournaments. + * These methods allow for creating, updating, fetching, and deleting tournaments. + * @interface ITournamentContextProps + */ +export interface ITournamentContextProps { + tournament: ITournamentResponse | null; + tournaments: ITournamentResponse[] | null; + + /** + * Adds a new tournament. + * @param tournament The details of the tournament to add. + * @returns A promise that resolves with the response containing the newly added tournament. + */ + addTournament( + tournament: IAddTournamentRequest + ): Promise; + + /** + * HU-38: creates a whole tournament (base fields + every division with its + * points, cups, playoff mappings and stages) in ONE atomic backend call + * (`POST /api/tournaments/full`). All-or-nothing: a failure leaves no partial + * tournament behind, and the tournament is created already + * `OpenForRegistration`. + * @param request The full tournament-wizard payload. + * @returns A promise resolving with the created tournament (including its divisions). + */ + createFullTournament( + request: ICreateFullTournamentRequest + ): Promise; + + /** + * HU-31/HU-112: adds ONE division (group stage + cups + playoff mappings) to + * an already-existing tournament in a single atomic transaction — the same + * structure guarantee a wizard-created division gets, instead of the bare + * division the granular add-division endpoint leaves behind. + * @param tournamentId The parent tournament's id. + * @param request The division's structure (zone or cross-cup). + * @returns A promise resolving with the created division. + */ + addFullDivision( + tournamentId: GUID, + request: ICreateFullDivisionRequest + ): Promise; + + /** + * Updates an existing tournament by its ID. + * @param id The ID of the tournament to update. + * @param tournament The updated tournament data. + * @returns A promise that resolves when the tournament is successfully updated. + */ + putTournamentById( + id: GUID, + tournament: IPutTournamentRequest + ): Promise; + + /** + * Fetches a tournament by its ID or its public slug. + * @param idOrSlug The ID or slug of the tournament to fetch. + * @returns A promise that resolves with the tournament details. + */ + getTournamentById( + idOrSlug: string, + options?: FetchOptions + ): Promise; + + /** + * Fetches tournaments based on filters. + * @param filter The filters to apply when fetching tournaments. + * @param options Per-call options; `silent` suppresses the global alert on failure. + * @returns A promise that resolves with the paginated response containing tournaments that match the filters. + */ + getAllTournamentsByFilter( + filter: ITournamentFiltered, + options?: FetchOptions + ): Promise | void>; + + /** + * Deletes a tournament by its ID. + * @param id The ID of the tournament to delete. + * @returns A promise resolving to `true` if the tournament was deleted, + * `false` if the request failed (the global error is already reported + * either way). + */ + deleteTournamentById(id: GUID): Promise; + + /** + * Enrolls a single team into a tournament during its registration phase + * (HU-107). Either an existing team is enrolled (existingTeamId) or a brand + * new team is created and enrolled in one step (newTeamName) — exactly one of + * the two. When an existing team is enrolled, its roster can be seeded from a + * previous season by passing copyRosterFromTournamentId. + * @param id The tournament identifier (GUID) to enroll the team into. + * @param request The enrollment payload (existing team or new team + optional roster copy). + * @returns A promise resolving to `true` on success, otherwise void. + */ + enrollTeam( + id: GUID, + request: IEnrollTeamRequest + ): Promise; + + /** + * Removes a team's enrollment from a tournament during its registration + * phase (HU-108). Hits `DELETE /api/tournaments/{id}/teams/{teamId}`, which + * the backend rejects with 409 once the tournament has started. + * @param id The tournament identifier (GUID) to unenroll the team from. + * @param teamId The team identifier (GUID) to unenroll. + * @returns A promise resolving to `true` on success, otherwise void. + */ + unenrollTeam(id: GUID, teamId: GUID): Promise; + + /** + * Fetches the live completability report for a tournament (HU-109) from + * `GET /api/tournaments/{id}/completability`. Reports whether the tournament + * can start and, if not, the list of blocking issues. + * @param id The tournament identifier (GUID). + * @returns A promise resolving to the completability report, or void on failure. + */ + getCompletability(id: GUID): Promise; + + /** + * HU-cloning: fetches a source tournament's full cloneable structure tree + * from `GET /api/tournaments/{idOrSlug}/structure`, for pre-filling the + * creation wizard. Carries no instance data (teams, matches, rosters). + * @param idOrSlug The source tournament's ID or public slug. + * @returns A promise resolving to the structure tree, or void on failure. + */ + getStructure(idOrSlug: string): Promise; +} + +/** + * One stage within a division's cloneable structure tree (HU-cloning). + * Mirrors the backend `StageStructureResponse` DTO. Carries no dates, no + * DrawnAt, and no match data. + * @interface IStageStructureResponse + */ +export interface IStageStructureResponse { + name: string; + bracketName?: string | null; + stageType: StageType; + isElimination: boolean; + order: number; + bestOf: number; + roundRobinLegs: number; +} + +/** + * One division within a tournament's cloneable structure tree (HU-cloning). + * Mirrors the backend `DivisionStructureResponse` DTO. + * @interface IDivisionStructureResponse + */ +export interface IDivisionStructureResponse { + name: string; + isCrossDivisionCup: boolean; + pointsForWin: number; + pointsForLoss: number; + qualifiersPerGroup: number; + playoffMappings: { + id: GUID; + fromPosition: number; + toPosition: number; + destination: string; + }[]; + stages: IStageStructureResponse[]; +} + +/** + * A source tournament's full cloneable structure tree (HU-cloning), returned + * by `GET /api/tournaments/{idOrSlug}/structure`. Mirrors the backend + * `TournamentStructureResponse` DTO. Carries STRUCTURE ONLY. + * @interface ITournamentStructureResponse + */ +export interface ITournamentStructureResponse { + name: string; + description?: string | null; + /** Shown for reference only — the clone action requires an explicit organizer choice, never inherited silently. */ + category: TournamentCategory; + divisions: IDivisionStructureResponse[]; +} + +/** + * A single blocking issue preventing a tournament from starting (HU-109), as + * returned by the completability endpoint. `code` is a stable machine-readable + * identifier; the optional fields carry the context each code needs to build a + * human-readable message. + * @interface ICompletabilityIssue + */ +export interface ICompletabilityIssue { + /** + * Stable issue code (e.g. `ZoneTooFewTeams`, `TeamNotAssigned`, + * `TeamInMultipleZones`, `PlayoffRangeExceedsTeams`, + * `CrossCupGroupTooFewTeams`, `TeamTooFewPlayers`, `SubGroupTooFewTeams`). + * @type {string} + */ + code: string; + + /** Name of the division/zone the issue refers to, when applicable. */ + divisionName?: string; + + /** Name of the team the issue refers to, when applicable. */ + teamName?: string; + + /** First standings position of the offending playoff range, when applicable. */ + fromPosition?: number; + + /** Number of teams assigned to the offending zone/group, when applicable. */ + assignedTeams?: number; + + /** The offending team's habilitado player count, for TeamTooFewPlayers. */ + playerCount?: number; +} + +/** + * The live completability report for a tournament (HU-109). + * @interface ITournamentCompletability + */ +export interface ITournamentCompletability { + /** Whether the tournament can transition to Ongoing (start). */ + canStart: boolean; + + /** The blocking issues; empty when `canStart` is true. */ + issues: ICompletabilityIssue[]; +} + +/** + * The request body structure for enrolling a team into a tournament (HU-107). + * Exactly one of `existingTeamId` / `newTeamName` must be provided. + * @interface IEnrollTeamRequest + */ +export interface IEnrollTeamRequest { + /** + * The identifier of an already-existing team (club) to enroll. + * @type {GUID} + */ + existingTeamId?: GUID; + + /** + * The name of a brand-new team to create and enroll in one step. + * @type {string} + */ + newTeamName?: string; + + /** + * When enrolling an existing team, the tournament (season) whose roster + * should be copied as the starting plantel for this enrollment. Typically the + * team's most recent season. + * @type {GUID} + */ + copyRosterFromTournamentId?: GUID; +} + +/** + * The request body structure for adding a new tournament. + * @interface IAddTournamentRequest + */ +export interface IAddTournamentRequest { + /** + * The name of the tournament. + * @type {string} + */ + name: string; + + /** + * A description of the tournament. + * @type {string} + */ + description: string; + + /** + * The deadline for team registrations. + * Must be earlier than the tournament start date. + * @type {Date} + */ + teamRegistrationDeadline: Date; + + /** + * The start date of the tournament. + * @type {Date} + */ + startDate: Date; + + /** + * Competitive category (gender) of the tournament (HU-48). Chosen at + * creation and immutable afterwards. The feminine competition is played as + * its own separate tournament. Omit to let the backend default to + * Masculine. + * @type {TournamentCategory} + */ + category?: TournamentCategory; + + /** + * Optional season ("Temporada") the tournament is grouped under. Omit to + * leave the tournament without a season. + * @type {GUID} + */ + seasonId?: GUID | null; +} + +/** + * The response structure for a tournament. + * @interface ITournamentResponse + */ +export interface ITournamentResponse { + /** + * The unique ID of the tournament. + * @type {GUID} + */ + id: GUID; + + /** + * A description of the tournament. + * @type {string} + */ + description: string; + + /** + * The name of the tournament. + * @type {string} + */ + name: string; + + /** + * The unique, URL-friendly identifier used in public tournament links. + * @type {string} + */ + slug: string; + + /** + * The divisions associated with the tournament. + * @type {IMinimalDivisionResponse[]} + */ + divisions: IMinimalDivisionResponse[]; + + /** + * The deadline for team registrations. + * Must be earlier than the tournament start date. + * @type {Date} + */ + teamRegistrationDeadline: Date; + + /** + * The start date of the tournament. + * @type {Date} + */ + startDate: Date; + + /** + * The current status of the tournament. + * @type {TournamentStatus} + */ + status: TournamentStatus; + + /** + * Competitive category (gender) of the tournament (HU-48). The feminine + * competition is played as its own separate tournament. + * @type {TournamentCategory} + */ + category: TournamentCategory; + + /** + * The season ("Temporada") the tournament is grouped under, or `null` when + * it belongs to no season. + * @type {GUID} + */ + seasonId: GUID | null; + + /** + * The name of the season the tournament belongs to, or `null` when it + * belongs to no season. + * @type {string} + */ + seasonName: string | null; + + /** + * Slug of the season this tournament belongs to, when the season was + * resolved by the backend; null otherwise. Prefer this over `seasonId` + * when building a link back to the season, so the URL never shows a raw + * UUID. + */ + seasonSlug?: string | null; +} + +/** + * The structure for filtering tournaments. + * @interface ITournamentFiltered + */ +export interface ITournamentFiltered extends Filtered { + /** + * The name of the tournament. + * @type {string} + */ + name?: string; + + /** + * A description of the tournament. + * @type {string} + */ + description?: string; + + /** + * The current status of the tournament. + * @type {TournamentStatus} + */ + status?: TournamentStatus; +} + +/** + * The request body structure for updating an existing tournament. + * @interface IPutTournamentRequest + */ +export interface IPutTournamentRequest { + /** + * The name of the tournament. + * @type {string} + */ + name: string; + + /** + * A description of the tournament. + * @type {string} + */ + description: string; + + /** + * The deadline for team registrations. + * Must be earlier than the tournament start date. + * @type {Date} + */ + teamRegistrationDeadline: Date; + + /** + * The start date of the tournament. + * @type {Date} + */ + startDate: Date; + + /** + * The current status of the tournament. + * @type {TournamentStatus} + */ + status?: TournamentStatus; + + /** + * Optional season ("Temporada") the tournament is grouped under. Send `null` + * to detach the tournament from its season. + * @type {GUID} + */ + seasonId?: GUID | null; +} + +export type StatisticsPositions = { + pj: number; + pg: number; + pp: number; + gf: number; + gc: number; + dif: number; + pts: number; +}; + +export type DataPositions = { + id: GUID; + nameTeam: string; + positions: StatisticsPositions; +}; diff --git a/Club12-WebClient/src/modules/tournament/utils/completabilityMessages.test.ts b/Club12-WebClient/src/modules/tournament/utils/completabilityMessages.test.ts new file mode 100644 index 0000000..ed906dc --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/utils/completabilityMessages.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; +import { completabilityIssueMessage } from '@/modules/tournament/utils/completabilityMessages'; +import type { ICompletabilityIssue } from '@/modules/tournament/type/tournament.d'; + +describe('completabilityIssueMessage', () => { + it('describes a zone with too few teams', () => { + const issue: ICompletabilityIssue = { + code: 'ZoneTooFewTeams', + divisionName: 'Zona A', + assignedTeams: 1, + }; + + expect(completabilityIssueMessage(issue)).toBe( + 'La zona Zona A tiene 1 equipos (mínimo 2).' + ); + }); + + it('describes an enrolled team without a zone', () => { + const issue: ICompletabilityIssue = { + code: 'TeamNotAssigned', + teamName: 'River', + }; + + expect(completabilityIssueMessage(issue)).toBe( + 'River está inscripto pero sin zona asignada.' + ); + }); + + it('describes a team assigned to more than one zone', () => { + const issue: ICompletabilityIssue = { + code: 'TeamInMultipleZones', + teamName: 'Boca', + }; + + expect(completabilityIssueMessage(issue)).toBe( + 'Boca está asignado a más de una zona.' + ); + }); + + it('describes a playoff range that exceeds the assigned teams', () => { + const issue: ICompletabilityIssue = { + code: 'PlayoffRangeExceedsTeams', + divisionName: 'Zona B', + fromPosition: 5, + assignedTeams: 3, + }; + + expect(completabilityIssueMessage(issue)).toBe( + 'En Zona B, un rango de playoff arranca en la posición 5 pero solo hay 3 equipos.' + ); + }); + + it('describes a cross-cup group with too few teams', () => { + const issue: ICompletabilityIssue = { + code: 'CrossCupGroupTooFewTeams', + assignedTeams: 1, + }; + + expect(completabilityIssueMessage(issue)).toBe( + 'Un grupo de la copa cruzada tiene 1 equipos (mínimo 2).' + ); + }); + + it('describes a sub-group with too few teams or an unbalanced split (HU-121, task 8.6)', () => { + const issue: ICompletabilityIssue = { + code: 'SubGroupTooFewTeams', + divisionName: 'Zona C', + assignedTeams: 3, + }; + + expect(completabilityIssueMessage(issue)).toBe( + 'En Zona C, un sub-grupo tiene 3 equipos (mínimo 4) o el reparto quedó desbalanceado.' + ); + }); + + it('describes a team with too few habilitado players', () => { + const issue: ICompletabilityIssue = { + code: 'TeamTooFewPlayers', + teamName: 'Independiente', + playerCount: 3, + }; + + expect(completabilityIssueMessage(issue)).toBe( + 'Independiente tiene 3 jugador(es) habilitado(s) (mínimo 4).' + ); + }); + + it('falls back to a generic message for an unknown code', () => { + const issue: ICompletabilityIssue = { code: 'SomethingElse' }; + + expect(completabilityIssueMessage(issue)).toBe( + 'Hay un problema de configuración que impide iniciar el torneo.' + ); + }); +}); diff --git a/Club12-WebClient/src/modules/tournament/utils/completabilityMessages.ts b/Club12-WebClient/src/modules/tournament/utils/completabilityMessages.ts new file mode 100644 index 0000000..a9ecd29 --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/utils/completabilityMessages.ts @@ -0,0 +1,34 @@ +import { ICompletabilityIssue } from '@/modules/tournament/type/tournament'; + +/** + * Builds a neutral, professional Spanish message describing a single + * completability issue (HU-109). The backend only sends a stable `code` plus + * the fields relevant to that code; the copy lives entirely on the client so + * the wording can evolve without a backend change. Unknown codes degrade to a + * generic message rather than surfacing a raw code to the admin. + * + * @param {ICompletabilityIssue} issue - The issue to describe. + * @returns {string} The human-readable Spanish message. + */ +export const completabilityIssueMessage = ( + issue: ICompletabilityIssue +): string => { + switch (issue.code) { + case 'ZoneTooFewTeams': + return `La zona ${issue.divisionName} tiene ${issue.assignedTeams} equipos (mínimo 2).`; + case 'TeamNotAssigned': + return `${issue.teamName} está inscripto pero sin zona asignada.`; + case 'TeamInMultipleZones': + return `${issue.teamName} está asignado a más de una zona.`; + case 'PlayoffRangeExceedsTeams': + return `En ${issue.divisionName}, un rango de playoff arranca en la posición ${issue.fromPosition} pero solo hay ${issue.assignedTeams} equipos.`; + case 'CrossCupGroupTooFewTeams': + return `Un grupo de la copa cruzada tiene ${issue.assignedTeams} equipos (mínimo 2).`; + case 'SubGroupTooFewTeams': + return `En ${issue.divisionName}, un sub-grupo tiene ${issue.assignedTeams} equipos (mínimo 4) o el reparto quedó desbalanceado.`; + case 'TeamTooFewPlayers': + return `${issue.teamName} tiene ${issue.playerCount} jugador(es) habilitado(s) (mínimo 4).`; + default: + return 'Hay un problema de configuración que impide iniciar el torneo.'; + } +}; diff --git a/Club12-WebClient/src/modules/tournament/utils/tournamentDisplay.ts b/Club12-WebClient/src/modules/tournament/utils/tournamentDisplay.ts new file mode 100644 index 0000000..61e3257 --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/utils/tournamentDisplay.ts @@ -0,0 +1,43 @@ +import { TournamentStatus } from '@/modules/core/enum/tournament/tournamentStatus'; +import { formatDateAr } from '@/modules/core/utils/formatDate'; + +export const TOURNAMENT_STATUS_LABEL: Record = { + Scheduled: 'Programado', + OpenForRegistration: 'Inscripción abierta', + RegistrationClosed: 'Inscripción cerrada', + Ongoing: 'En curso', + Finished: 'Finalizado', + Canceled: 'Cancelado', +}; + +export const TOURNAMENT_STATUS_COLOR: Record< + TournamentStatus, + 'default' | 'info' | 'secondary' | 'warning' | 'success' | 'error' +> = { + Scheduled: 'default', + OpenForRegistration: 'info', + RegistrationClosed: 'secondary', + Ongoing: 'warning', + Finished: 'success', + Canceled: 'error', +}; + +/** + * Narrows an unknown status value (as received from the API) to a known + * TournamentStatus, defaulting to Scheduled for anything unrecognized. Shared + * so every list/detail view resolves status the same way and no view silently + * coerces a valid status it forgot to whitelist. + */ +export const resolveTournamentStatus = (status: unknown): TournamentStatus => { + if ( + typeof status === 'string' && + (Object.values(TournamentStatus) as string[]).includes(status) + ) { + return status as TournamentStatus; + } + + return TournamentStatus.Scheduled; +}; + +export const formatTournamentDate = (value: Date | string): string => + formatDateAr(value); diff --git a/Club12-WebClient/src/modules/tournament/utils/tournamentStatusTransitions.test.ts b/Club12-WebClient/src/modules/tournament/utils/tournamentStatusTransitions.test.ts new file mode 100644 index 0000000..6442121 --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/utils/tournamentStatusTransitions.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from 'vitest'; +import { TournamentStatus } from '@/modules/core/enum/tournament/tournamentStatus'; +import { + getNextStatusOptions, + isValidStatusTransition, + isTerminalStatus, +} from './tournamentStatusTransitions'; + +describe('getNextStatusOptions', () => { + it('offers OpenForRegistration or Canceled from Scheduled', () => { + expect(getNextStatusOptions(TournamentStatus.Scheduled)).toEqual([ + TournamentStatus.OpenForRegistration, + TournamentStatus.Canceled, + ]); + }); + + it('offers RegistrationClosed or Canceled from OpenForRegistration', () => { + expect(getNextStatusOptions(TournamentStatus.OpenForRegistration)).toEqual([ + TournamentStatus.RegistrationClosed, + TournamentStatus.Canceled, + ]); + }); + + it('offers Ongoing or Canceled from RegistrationClosed', () => { + expect(getNextStatusOptions(TournamentStatus.RegistrationClosed)).toEqual([ + TournamentStatus.Ongoing, + TournamentStatus.Canceled, + ]); + }); + + it('offers RegistrationClosed (revertir a borrador), Finished or Canceled from Ongoing', () => { + expect(getNextStatusOptions(TournamentStatus.Ongoing)).toEqual([ + TournamentStatus.RegistrationClosed, + TournamentStatus.Finished, + TournamentStatus.Canceled, + ]); + }); + + it('offers nothing from the terminal statuses', () => { + expect(getNextStatusOptions(TournamentStatus.Finished)).toEqual([]); + expect(getNextStatusOptions(TournamentStatus.Canceled)).toEqual([]); + }); +}); + +describe('isValidStatusTransition', () => { + it('accepts each forward step of the happy path', () => { + expect( + isValidStatusTransition( + TournamentStatus.Scheduled, + TournamentStatus.OpenForRegistration + ) + ).toBe(true); + expect( + isValidStatusTransition( + TournamentStatus.OpenForRegistration, + TournamentStatus.RegistrationClosed + ) + ).toBe(true); + expect( + isValidStatusTransition( + TournamentStatus.RegistrationClosed, + TournamentStatus.Ongoing + ) + ).toBe(true); + expect( + isValidStatusTransition( + TournamentStatus.Ongoing, + TournamentStatus.Finished + ) + ).toBe(true); + }); + + it('treats a no-op transition to the same status as valid', () => { + expect( + isValidStatusTransition( + TournamentStatus.Ongoing, + TournamentStatus.Ongoing + ) + ).toBe(true); + }); + + it('rejects skipping a step', () => { + expect( + isValidStatusTransition( + TournamentStatus.Scheduled, + TournamentStatus.RegistrationClosed + ) + ).toBe(false); + expect( + isValidStatusTransition( + TournamentStatus.OpenForRegistration, + TournamentStatus.Ongoing + ) + ).toBe(false); + }); + + it('rejects moving backward, except the deliberate Ongoing revert', () => { + expect( + isValidStatusTransition( + TournamentStatus.RegistrationClosed, + TournamentStatus.OpenForRegistration + ) + ).toBe(false); + }); + + it('accepts reverting Ongoing back to RegistrationClosed ("revertir a borrador")', () => { + expect( + isValidStatusTransition( + TournamentStatus.Ongoing, + TournamentStatus.RegistrationClosed + ) + ).toBe(true); + }); + + it('rejects leaving a terminal status', () => { + expect( + isValidStatusTransition( + TournamentStatus.Finished, + TournamentStatus.Ongoing + ) + ).toBe(false); + expect( + isValidStatusTransition( + TournamentStatus.Canceled, + TournamentStatus.Scheduled + ) + ).toBe(false); + }); + + it('allows cancelling from any non-terminal status', () => { + expect( + isValidStatusTransition( + TournamentStatus.Scheduled, + TournamentStatus.Canceled + ) + ).toBe(true); + expect( + isValidStatusTransition( + TournamentStatus.RegistrationClosed, + TournamentStatus.Canceled + ) + ).toBe(true); + }); +}); + +describe('isTerminalStatus', () => { + it('is true only for Finished and Canceled', () => { + expect(isTerminalStatus(TournamentStatus.Finished)).toBe(true); + expect(isTerminalStatus(TournamentStatus.Canceled)).toBe(true); + expect(isTerminalStatus(TournamentStatus.Scheduled)).toBe(false); + expect(isTerminalStatus(TournamentStatus.RegistrationClosed)).toBe(false); + }); +}); diff --git a/Club12-WebClient/src/modules/tournament/utils/tournamentStatusTransitions.ts b/Club12-WebClient/src/modules/tournament/utils/tournamentStatusTransitions.ts new file mode 100644 index 0000000..51f1dd4 --- /dev/null +++ b/Club12-WebClient/src/modules/tournament/utils/tournamentStatusTransitions.ts @@ -0,0 +1,59 @@ +import { TournamentStatus } from '@/modules/core/enum/tournament/tournamentStatus'; + +/** + * Client mirror of the backend lifecycle state machine + * (Domain.Enums.TournamentStatusTransitions). Transitions are forward-only + * along Scheduled -> OpenForRegistration -> RegistrationClosed -> Ongoing -> + * Finished, with Canceled reachable from any non-terminal state, PLUS one + * deliberate reversal: Ongoing -> RegistrationClosed ("revertir a + * borrador") — the backend allows undoing a tournament start as long as no + * match has been played yet (rejected with 409 otherwise), tearing down the + * generated fixture while keeping team-to-zone assignments. Finished and + * Canceled are terminal. The backend is the source of truth; this map only + * keeps the UI from offering moves the server would reject. + */ +export const TOURNAMENT_STATUS_NEXT_STATES: Record< + TournamentStatus, + TournamentStatus[] +> = { + [TournamentStatus.Scheduled]: [ + TournamentStatus.OpenForRegistration, + TournamentStatus.Canceled, + ], + [TournamentStatus.OpenForRegistration]: [ + TournamentStatus.RegistrationClosed, + TournamentStatus.Canceled, + ], + [TournamentStatus.RegistrationClosed]: [ + TournamentStatus.Ongoing, + TournamentStatus.Canceled, + ], + [TournamentStatus.Ongoing]: [ + TournamentStatus.RegistrationClosed, + TournamentStatus.Finished, + TournamentStatus.Canceled, + ], + [TournamentStatus.Finished]: [], + [TournamentStatus.Canceled]: [], +}; + +/** + * The valid next statuses a tournament may transition to from its current + * status (excluding the current status itself). Empty for terminal statuses. + */ +export const getNextStatusOptions = ( + current: TournamentStatus +): TournamentStatus[] => TOURNAMENT_STATUS_NEXT_STATES[current] ?? []; + +/** + * Whether moving from `from` to `to` is allowed. A no-op transition to the + * same status is treated as valid (the backend ignores it). + */ +export const isValidStatusTransition = ( + from: TournamentStatus, + to: TournamentStatus +): boolean => from === to || getNextStatusOptions(from).includes(to); + +/** Whether a status has no outgoing transition (Finished or Canceled). */ +export const isTerminalStatus = (status: TournamentStatus): boolean => + getNextStatusOptions(status).length === 0; diff --git a/Club12-WebClient/src/modules/user/context/user.context.tsx b/Club12-WebClient/src/modules/user/context/user.context.tsx new file mode 100644 index 0000000..9007432 --- /dev/null +++ b/Club12-WebClient/src/modules/user/context/user.context.tsx @@ -0,0 +1,216 @@ +import { AxiosResponse } from 'axios'; +import { + createContext, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useQueryClient } from '@tanstack/react-query'; +import { ProviderProps } from '@/modules/core/types/types'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { userService } from '@/modules/user/service/user.service'; +import { IUserContextProps } from '@/modules/user/type/user'; +import { + ChangePasswordRequest, + RegisterUserRequest, + RegisterUserResponse, + UpdateUserRequest, + UserFilterRequest, + UserResponse, +} from '@/modules/user/type/user'; +import { userKeys } from '@/modules/user/queryKeys'; + +export const UserContext = createContext( + undefined +); + +export const UserProvider: React.FC = ({ children }) => { + const [user, setUser] = useState(null); + const [users, setUsers] = useState(null); + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + useEffect(() => { + if (!user) { + return; + } + + setUsers(prev => { + const list = prev ?? []; + const index = list.findIndex(u => u.userId === user.userId); + if (index === -1) return [...list, user]; + const updated = [...list]; + updated[index] = user; + return updated; + }); + }, [user]); + + const getAllUsers = useCallback( + async (filters: UserFilterRequest) => { + try { + const res = await queryClient.fetchQuery({ + queryKey: userKeys.list(filters), + queryFn: async () => await userService.getAllUsers(filters), + }); + + if (res?.data?.items) { + setUsers(res.data.items); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError, queryClient] + ); + + const getById = useCallback( + async (id: UserResponse['userId']) => { + try { + const res: AxiosResponse = await queryClient.fetchQuery({ + queryKey: userKeys.byId(id), + queryFn: async () => await userService.getById(id), + }); + + if (res?.data) { + setUser(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError, queryClient] + ); + + const createUser = useCallback( + async (data: RegisterUserRequest) => { + try { + const res: AxiosResponse = + await userService.createUser(data); + if (res?.data) { + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError] + ); + + const updateUser = useCallback( + async (id: UserResponse['userId'], data: UpdateUserRequest) => { + try { + const res: AxiosResponse = await userService.updateUser( + id, + data + ); + if (res?.data) { + setUser(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError] + ); + + const resetUserPassword = useCallback( + async (id: UserResponse['userId']): Promise => { + try { + await userService.resetUserPassword(id); + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [handleUnknownError] + ); + + const changeUserPassword = useCallback( + async ( + id: UserResponse['userId'], + data: ChangePasswordRequest + ): Promise => { + try { + await userService.changeUserPassword(id, data); + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [handleUnknownError] + ); + + const deleteUser = useCallback( + async (id: UserResponse['userId']): Promise => { + try { + await userService.deleteUser(id); + setUsers(prev => prev?.filter(u => u.userId !== id) ?? null); + setUser(prev => (prev?.userId === id ? null : prev)); + queryClient.removeQueries({ queryKey: userKeys.byId(id) }); + await queryClient.invalidateQueries({ queryKey: userKeys.list() }); + return true; + } catch (error: unknown) { + handleUnknownError(error); + return false; + } + }, + [handleUnknownError, queryClient] + ); + + const setUserActive = useCallback( + async ( + id: UserResponse['userId'], + isActive: boolean + ): Promise => { + try { + const res: AxiosResponse = + await userService.setUserActive(id, isActive); + if (res?.data) { + setUser(res.data); + return res.data; + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [handleUnknownError] + ); + + const contextValue = useMemo( + () => ({ + user, + users, + getAllUsers, + getById, + createUser, + updateUser, + resetUserPassword, + changeUserPassword, + deleteUser, + setUserActive, + }), + [ + user, + users, + getAllUsers, + getById, + createUser, + updateUser, + resetUserPassword, + changeUserPassword, + deleteUser, + setUserActive, + ] + ); + + return ( + {children} + ); +}; diff --git a/Club12-WebClient/src/modules/user/hook/user.hook.ts b/Club12-WebClient/src/modules/user/hook/user.hook.ts new file mode 100644 index 0000000..c1c3238 --- /dev/null +++ b/Club12-WebClient/src/modules/user/hook/user.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { UserContext } from '@/modules/user/context/user.context'; + +export const useUser = () => { + const context = useContext(UserContext); + if (!context) { + throw new Error('useUser must be used within a UserProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/user/queryKeys.test.ts b/Club12-WebClient/src/modules/user/queryKeys.test.ts new file mode 100644 index 0000000..3eb4524 --- /dev/null +++ b/Club12-WebClient/src/modules/user/queryKeys.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { userKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; +import { UserFilterRequest } from '@/modules/user/type/user'; + +describe('userKeys', () => { + const id: GUID = '66666666-6666-6666-6666-666666666666'; + + it('list() returns the bare list literal with no trailing undefined', () => { + expect(userKeys.list()).toEqual(['user', 'list']); + }); + + it('list(filter) returns the filtered list literal', () => { + const filter: UserFilterRequest = { pageNumber: 1 }; + expect(userKeys.list(filter)).toEqual(['user', 'list', filter]); + }); + + it('byId(id) returns the by-id literal', () => { + expect(userKeys.byId(id)).toEqual(['user', 'byId', id]); + }); +}); diff --git a/Club12-WebClient/src/modules/user/queryKeys.ts b/Club12-WebClient/src/modules/user/queryKeys.ts new file mode 100644 index 0000000..7437d45 --- /dev/null +++ b/Club12-WebClient/src/modules/user/queryKeys.ts @@ -0,0 +1,10 @@ +import { GUID } from '@/modules/core/types/types'; +import { UserFilterRequest } from '@/modules/user/type/user'; + +export const userKeys = { + list: (filter?: UserFilterRequest) => + filter === undefined + ? (['user', 'list'] as const) + : (['user', 'list', filter] as const), + byId: (id: GUID) => ['user', 'byId', id] as const, +}; diff --git a/Club12-WebClient/src/modules/user/service/user.service.ts b/Club12-WebClient/src/modules/user/service/user.service.ts new file mode 100644 index 0000000..212c539 --- /dev/null +++ b/Club12-WebClient/src/modules/user/service/user.service.ts @@ -0,0 +1,57 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { withTablePageSize } from '@/modules/core/constants/pagination'; +import { GenericResponsePagination, GUID } from '@/modules/core/types/types'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + ChangePasswordRequest, + RegisterUserRequest, + RegisterUserResponse, + UpdateUserRequest, + UserFilterRequest, + UserResponse, +} from '@/modules/user/type/user'; + +export const userService = { + getAllUsers: async ( + filters: UserFilterRequest + ): Promise>> => + await sendGet(routes.users, withTablePageSize(filters)), + + getById: async (id: GUID): Promise> => + await sendGet(`${routes.users}/${id}`), + + createUser: async ( + data: RegisterUserRequest + ): Promise> => + await sendPost(`${routes.auth}/register`, data), + + updateUser: async ( + id: GUID, + data: UpdateUserRequest + ): Promise> => + await sendPut(`${routes.users}/${id}`, data), + + resetUserPassword: async (id: GUID): Promise> => + await sendPost(`${routes.users}/${id}/password/reset`), + + changeUserPassword: async ( + id: GUID, + data: ChangePasswordRequest + ): Promise> => + await sendPut(`${routes.users}/${id}/password`, data), + + deleteUser: async (id: GUID): Promise> => + await sendDelete(`${routes.users}/${id}`), + + setUserActive: async ( + id: GUID, + isActive: boolean + ): Promise> => + await sendPut(`${routes.users}/${id}/active`, { isActive }), +}; diff --git a/Club12-WebClient/src/modules/user/type/user.d.ts b/Club12-WebClient/src/modules/user/type/user.d.ts new file mode 100644 index 0000000..4a3d8ee --- /dev/null +++ b/Club12-WebClient/src/modules/user/type/user.d.ts @@ -0,0 +1,78 @@ +import { UserRolesType } from '@/modules/core/enum/user/userRolesType'; +import { + Filtered, + GenericResponsePagination, + GUID, +} from '@/modules/core/types/types'; + +export interface UserResponse { + userId: GUID; + username: string; + email: string; + phoneNumber?: string; + role: UserRolesType; + createdByOwnerId?: GUID; + isActive: boolean; +} + +export interface RegisterUserRequest { + email: string; + username: string; + phone?: string; + role: string; +} + +export interface RegisterUserResponse { + userId: GUID; + email: string; + username: string; + role: string; + phoneNumber?: string; +} + +export interface UpdateUserRequest { + username?: string; + email?: string; + phone?: string; + /** + * Optional. When set, replaces the target user's role. Only ADMIN and + * OWNER may set this, and the caller's exact assignment policy (plus the + * guard against changing your own role) is enforced server-side. + */ + role?: UserRolesType; +} + +export interface ChangePasswordRequest { + newPassword: string; + currentPassword?: string; +} + +export interface IUserContextProps { + user: UserResponse | null; + users: UserResponse[] | null; + getAllUsers: ( + filters: UserFilterRequest + ) => Promise | void>; + getById: (id: GUID) => Promise; + createUser: ( + data: RegisterUserRequest + ) => Promise; + updateUser: ( + id: GUID, + data: UpdateUserRequest + ) => Promise; + resetUserPassword: (id: GUID) => Promise; + changeUserPassword: ( + id: GUID, + data: ChangePasswordRequest + ) => Promise; + deleteUser: (id: GUID) => Promise; + setUserActive: (id: GUID, isActive: boolean) => Promise; +} + +export interface UserFilterRequest extends Filtered { + username?: string; + email?: string; + phoneNumber?: string; + role?: UserRolesType; +} diff --git a/Club12-WebClient/src/modules/venue/context/venue.context.test.tsx b/Club12-WebClient/src/modules/venue/context/venue.context.test.tsx new file mode 100644 index 0000000..4128b55 --- /dev/null +++ b/Club12-WebClient/src/modules/venue/context/venue.context.test.tsx @@ -0,0 +1,139 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ReactNode } from 'react'; +import Swal from 'sweetalert2'; +import { ErrorProvider } from '@/modules/error/context/error.context'; +import { VenueProvider } from '@/modules/venue/context/venue.context'; +import { useVenue } from '@/modules/venue/hook/venue.hook'; +import { venueService } from '@/modules/venue/service/venue.service'; +import type { GUID } from '@/modules/core/types/types'; +import type { IVenueResponse } from '@/modules/venue/type/venue'; + +vi.mock('@/modules/venue/service/venue.service'); +vi.mock('sweetalert2', () => ({ + default: { + fire: vi.fn(), + getContainer: vi.fn().mockReturnValue(null), + }, +})); + +const mockedGetAllVenues = vi.mocked(venueService.getAllVenues); +const mockedGetVenueById = vi.mocked(venueService.getVenueById); +const mockedPutVenueById = vi.mocked(venueService.putVenueById); +const mockedPutVenuePhotoById = vi.mocked(venueService.putVenuePhotoById); +const mockedDeleteVenueById = vi.mocked(venueService.deleteVenueById); +const mockedSwalFire = vi.mocked(Swal.fire); + +const VENUE_ID = '44444444-4444-4444-4444-444444444444' as GUID; + +const buildVenue = (overrides: Partial = {}): IVenueResponse => ({ + id: VENUE_ID, + slug: 'cancha-central', + name: 'Cancha Central', + address: 'Calle 123', + photoUrl: 'https://cdn.test/venue/old.jpg', + ...overrides, +}); + +const wrapper = ({ children }: { children: ReactNode }) => ( + + + {children} + + +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('VenueProvider — no duplicate success toast', () => { + /** + * venuePage.tsx / VenuesPage.tsx already show their own "Cancha actualizada" + * confirmation for these actions. The context used to ALSO fire a generic + * toast, so the user saw two modals with the same message for one save. + */ + it('does not fire its own toast after putVenueById succeeds (200)', async () => { + mockedPutVenueById.mockResolvedValueOnce({ + status: 200, + data: buildVenue({ name: 'Cancha Norte' }), + } as never); + + const { result } = renderHook(() => useVenue(), { wrapper }); + await act(async () => { + await result.current.putVenueById(VENUE_ID, { + name: 'Cancha Norte', + address: 'Calle 123', + }); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('does not fire its own toast after putVenueById succeeds (204)', async () => { + mockedPutVenueById.mockResolvedValueOnce({ status: 204 } as never); + + const { result } = renderHook(() => useVenue(), { wrapper }); + await act(async () => { + await result.current.putVenueById(VENUE_ID, { + name: 'Cancha Norte', + address: 'Calle 123', + }); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); + + it('does not fire its own toast after deleteVenueById succeeds', async () => { + mockedDeleteVenueById.mockResolvedValueOnce({ status: 204 } as never); + + const { result } = renderHook(() => useVenue(), { wrapper }); + await act(async () => { + await result.current.deleteVenueById(VENUE_ID); + }); + + expect(mockedSwalFire).not.toHaveBeenCalled(); + }); +}); + +describe('VenueProvider — photo upload refreshes the venue', () => { + /** + * The photo endpoint returns no body and every upload lands at a fresh + * unique URL (SupabaseHelper.GenerateNameFile), so the new photoUrl is only + * knowable via a real GET. getVenueById short-circuits on the in-memory + * `venues` list, which still holds the pre-upload URL — so putVenuePhotoById + * must hit the service directly, otherwise the image only updates on a full + * page reload. + */ + it('refetches over the network even when the venue is already cached', async () => { + mockedGetAllVenues.mockResolvedValueOnce({ + status: 200, + data: [buildVenue()], + } as never); + mockedPutVenuePhotoById.mockResolvedValueOnce({ status: 204 } as never); + mockedGetVenueById.mockResolvedValueOnce({ + status: 200, + data: buildVenue({ photoUrl: 'https://cdn.test/venue/new.jpg' }), + } as never); + + const { result } = renderHook(() => useVenue(), { wrapper }); + await act(async () => { + await result.current.getAllVenues(); + }); + await waitFor(() => expect(result.current.venues).toHaveLength(1)); + + const file = new File(['x'], 'court.jpg', { type: 'image/jpeg' }); + const returned = await act(() => + result.current.putVenuePhotoById(VENUE_ID, file) + ); + + expect(mockedGetVenueById).toHaveBeenCalledWith(VENUE_ID); + expect(returned).toEqual( + expect.objectContaining({ photoUrl: 'https://cdn.test/venue/new.jpg' }) + ); + expect(result.current.venue?.photoUrl).toBe( + 'https://cdn.test/venue/new.jpg' + ); + }); +}); diff --git a/Club12-WebClient/src/modules/venue/context/venue.context.tsx b/Club12-WebClient/src/modules/venue/context/venue.context.tsx new file mode 100644 index 0000000..d2d733b --- /dev/null +++ b/Club12-WebClient/src/modules/venue/context/venue.context.tsx @@ -0,0 +1,259 @@ +import { AxiosResponse } from 'axios'; +import { + createContext, + ReactNode, + useEffect, + useState, + useCallback, + useMemo, +} from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useError } from '@/modules/error/hooks/error.hock'; +import { useUnknownErrorHandler } from '@/modules/error/hooks/useUnknownErrorHandler'; +import { venueService } from '@/modules/venue/service/venue.service'; +import { + IAddVenueRequest, + IVenueContextProps, + IPutVenueRequest, + IVenueResponse, +} from '@/modules/venue/type/venue'; +import { GUID } from '@/modules/core/types/types'; +import { upsertListById } from '@/modules/core/utils/synchronizeStates'; +import { venueKeys } from '@/modules/venue/queryKeys'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; +import { + extractProblemDetail, + MutationResult, +} from '@/modules/core/utils/problemDetails'; + +export const VenueContext = createContext( + undefined +); + +export const VenueProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [venue, setVenue] = useState(null); + const [venues, setVenues] = useState(null); + + const { setMessage } = useError(); + const queryClient = useQueryClient(); + + const handleUnknownError = useUnknownErrorHandler(); + + const addVenueMutation = useMutation({ + mutationFn: venueService.addVenue, + }); + + const putVenueMutation = useMutation({ + mutationFn: ({ id, venue }: { id: GUID; venue: IPutVenueRequest }) => + venueService.putVenueById(id, venue), + }); + + const deleteVenueMutation = useMutation({ + mutationFn: venueService.deleteVenueById, + }); + + useEffect(() => { + if (!venue) return; + setVenues(prev => upsertListById(prev, venue)); + }, [venue]); + + const addVenue = useCallback( + async (venue: IAddVenueRequest): Promise => { + try { + const res: AxiosResponse = + await addVenueMutation.mutateAsync(venue); + + if (res) { + setVenue(res.data); + queryClient.setQueryData(venueKeys.byId(res.data.id), res); + await queryClient.invalidateQueries({ queryKey: venueKeys.list() }); + setMessage(res.status, ['La cancha fue creada exitosamente.']); + } + + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [addVenueMutation, queryClient, setMessage, handleUnknownError] + ); + + const putVenueById = useCallback( + async ( + id: GUID, + venue: IPutVenueRequest + ): Promise => { + try { + const res: AxiosResponse = + await putVenueMutation.mutateAsync({ id, venue }); + + if (res) { + // Success feedback belongs to the calling page (venuePage.tsx / + // VenuesPage.tsx already show their own confirmation) — firing a + // toast here too shows the user two modals for one save. + if (res.status === HttpStatus.NoContent) { + const currentVenue = + venues?.find(existingVenue => existingVenue.id === id) ?? null; + const updatedVenue: IVenueResponse = { + id, + slug: currentVenue?.slug ?? '', + name: venue.name ?? currentVenue?.name ?? '', + address: venue.address ?? currentVenue?.address ?? '', + photoUrl: venue.photoUrl ?? currentVenue?.photoUrl ?? '', + latitude: venue.latitude ?? currentVenue?.latitude, + longitude: venue.longitude ?? currentVenue?.longitude, + }; + setVenue(updatedVenue); + setVenues(prev => upsertListById(prev, updatedVenue)); + await queryClient.invalidateQueries({ + queryKey: venueKeys.list(), + }); + return updatedVenue; + } else if (res.data) { + setVenue(res.data); + setVenues(prev => upsertListById(prev, res.data)); + queryClient.setQueryData(venueKeys.byId(id), res); + await queryClient.invalidateQueries({ + queryKey: venueKeys.list(), + }); + return res.data; + } + } + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [putVenueMutation, queryClient, venues, handleUnknownError] + ); + + const putVenuePhotoById = useCallback( + async (id: GUID, image: File): Promise => { + try { + await venueService.putVenuePhotoById(id, image); + // The photo endpoint returns no body and each upload lands at a new + // unique URL, so the fresh photoUrl is only knowable via a real GET. + // Call the service directly to bypass the in-memory `venues` + // short-circuit in getVenueById, which still holds the pre-upload URL — + // without this the image only updates after a full page reload. + const res: AxiosResponse = + await venueService.getVenueById(id); + setVenue(res.data); + setVenues(prev => upsertListById(prev, res.data)); + queryClient.setQueryData(venueKeys.byId(id), res); + await queryClient.invalidateQueries({ queryKey: venueKeys.list() }); + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [queryClient, handleUnknownError] + ); + + const getAllVenues = useCallback(async (): Promise< + IVenueResponse[] | void + > => { + try { + const res: AxiosResponse = await queryClient.fetchQuery( + { + queryKey: venueKeys.list(), + queryFn: async () => await venueService.getAllVenues(), + } + ); + + if (res) { + setVenues(res.data); + } + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, [queryClient, handleUnknownError]); + + const getVenueById = useCallback( + async (idOrSlug: string): Promise => { + try { + const existingVenue = venues?.find( + e => e.id === idOrSlug || e.slug === idOrSlug + ); + + if (existingVenue) { + setVenue(existingVenue); + return existingVenue; + } + const res: AxiosResponse = await queryClient.fetchQuery( + { + queryKey: venueKeys.byId(idOrSlug), + queryFn: async () => await venueService.getVenueById(idOrSlug), + } + ); + + if (res) { + setVenue(res.data); + } + + return res.data; + } catch (error: unknown) { + handleUnknownError(error); + } + }, + [venues, queryClient, handleUnknownError] + ); + + /** + * A venue delete can be blocked by a backend integrity rule (a venue still + * referenced by matches returns a 409 with a Spanish message). The failure is + * returned as a discriminated result so the caller can surface that exact + * reason inline instead of swallowing it. + */ + const deleteVenueById = useCallback( + async (id: GUID): Promise => { + try { + await deleteVenueMutation.mutateAsync(id); + setVenue(null); + setVenues(prev => (prev ? prev.filter(e => e.id !== id) : null)); + queryClient.removeQueries({ queryKey: venueKeys.byId(id) }); + await queryClient.invalidateQueries({ queryKey: venueKeys.list() }); + // Success feedback belongs to the calling page (VenuesPage shows its own + // "¡Eliminada!" confirmation) — a toast here too means two modals. + return { success: true }; + } catch (error: unknown) { + return { + success: false, + errorMessage: + extractProblemDetail(error) ?? + 'No se pudo eliminar la cancha. Intentá nuevamente.', + }; + } + }, + [deleteVenueMutation, queryClient] + ); + + const container: IVenueContextProps = useMemo( + () => ({ + venue, + venues, + addVenue, + getVenueById, + getAllVenues, + putVenueById, + putVenuePhotoById, + deleteVenueById, + }), + [ + venue, + venues, + addVenue, + getVenueById, + getAllVenues, + putVenueById, + putVenuePhotoById, + deleteVenueById, + ] + ); + + return ( + {children} + ); +}; diff --git a/Club12-WebClient/src/modules/venue/hook/venue.hook.ts b/Club12-WebClient/src/modules/venue/hook/venue.hook.ts new file mode 100644 index 0000000..6959b71 --- /dev/null +++ b/Club12-WebClient/src/modules/venue/hook/venue.hook.ts @@ -0,0 +1,10 @@ +import { useContext } from 'react'; +import { VenueContext } from '@/modules/venue/context/venue.context'; + +export const useVenue = () => { + const context = useContext(VenueContext); + if (!context) { + throw new Error('useVenue must be used within a VenueProvider'); + } + return context; +}; diff --git a/Club12-WebClient/src/modules/venue/queryKeys.test.ts b/Club12-WebClient/src/modules/venue/queryKeys.test.ts new file mode 100644 index 0000000..74b49b1 --- /dev/null +++ b/Club12-WebClient/src/modules/venue/queryKeys.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { venueKeys } from './queryKeys'; +import { GUID } from '@/modules/core/types/types'; + +describe('venueKeys', () => { + const id: GUID = '33333333-3333-3333-3333-333333333333'; + + it('list() returns the bare list literal', () => { + expect(venueKeys.list()).toEqual(['venue', 'list']); + }); + + it('byId(id) returns the by-id literal', () => { + expect(venueKeys.byId(id)).toEqual(['venue', 'byId', id]); + }); +}); diff --git a/Club12-WebClient/src/modules/venue/queryKeys.ts b/Club12-WebClient/src/modules/venue/queryKeys.ts new file mode 100644 index 0000000..25d6b67 --- /dev/null +++ b/Club12-WebClient/src/modules/venue/queryKeys.ts @@ -0,0 +1,4 @@ +export const venueKeys = { + list: () => ['venue', 'list'] as const, + byId: (idOrSlug: string) => ['venue', 'byId', idOrSlug] as const, +}; diff --git a/Club12-WebClient/src/modules/venue/service/venue.service.ts b/Club12-WebClient/src/modules/venue/service/venue.service.ts new file mode 100644 index 0000000..3084e53 --- /dev/null +++ b/Club12-WebClient/src/modules/venue/service/venue.service.ts @@ -0,0 +1,103 @@ +import { AxiosResponse } from 'axios'; +import routes from '@/modules/core/constants/routes'; +import { + sendDelete, + sendGet, + sendPost, + sendPut, +} from '@/modules/core/utils/axiosUtils'; +import { + IAddVenueRequest, + IPutVenueRequest, + IVenueResponse, +} from '@/modules/venue/type/venue'; +import { GUID } from '@/modules/core/types/types'; + +/** + * Service for managing venues. + */ +export const venueService = { + /** + * Adds a new venue. + * @param {IAddVenueRequest} venue - The venue details to add. + * @returns {Promise>} The server response. + */ + addVenue: async ( + venue: IAddVenueRequest + ): Promise> => { + const formData = new FormData(); + formData.append('Name', venue.name); + formData.append('Address', venue.address); + if (venue.imageFile) { + formData.append('ImageFile', venue.imageFile); + } + if (venue.latitude !== undefined) { + formData.append('Latitude', String(venue.latitude)); + } + if (venue.longitude !== undefined) { + formData.append('Longitude', String(venue.longitude)); + } + return await sendPost(routes.venues, formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + }, + + /** + * Updates the photo for a specific venue. The backend binds + * [FromForm] UpdateVenuePhotoRequest, which requires both a VenueId and the + * ImageFile — send a proper multipart body. + * @param {string} id - The ID of the venue to update. + * @param {File} image - The new image file. + * @returns {Promise>} The server response. + */ + putVenuePhotoById: async ( + id: GUID, + image: File + ): Promise> => { + const formData = new FormData(); + formData.append('VenueId', id); + formData.append('ImageFile', image); + return await sendPut(`${routes.venues}/${id}/photo`, formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + }, + + /** + * Updates an existing venue. + * @param {string} id - The ID of the venue to update. + * @param {IPutVenueRequest} venue - The updated venue details. + * @returns {Promise>} The server response. + */ + putVenueById: async ( + id: GUID, + venue: IPutVenueRequest + ): Promise> => + await sendPut(`${routes.venues}/${id}`, venue), + + /** + * Retrieves all venues. + * @returns {Promise>} The server response containing the list of venues. + */ + getAllVenues: async (): Promise> => + await sendGet(routes.venues), + + /** + * Retrieves a venue by its ID or its public slug. + * @param {string} idOrSlug - The ID or slug of the venue to retrieve. + * @returns {Promise>} The server response containing the venue details. + */ + getVenueById: async ( + idOrSlug: string + ): Promise> => + await sendGet(`${routes.venues}/${idOrSlug}`), + + /** + * Deletes a venue by its ID. + * @param {string} id - The ID of the venue to delete. + * @returns {Promise>} The server response. + */ + deleteVenueById: async (id: GUID): Promise> => + await sendDelete(`${routes.venues}/${id}`), +}; diff --git a/Club12-WebClient/src/modules/venue/type/venue.d.ts b/Club12-WebClient/src/modules/venue/type/venue.d.ts new file mode 100644 index 0000000..2647ded --- /dev/null +++ b/Club12-WebClient/src/modules/venue/type/venue.d.ts @@ -0,0 +1,181 @@ +import { GUID } from '@/modules/core/types/types'; +import { MutationResult } from '@/modules/core/utils/problemDetails'; + +/** + * Context properties and methods for managing venue data in a React application. + * These methods interact with the backend for creating, updating, fetching, and deleting venues. + * @interface IVenueContextProps + */ +export interface IVenueContextProps { + venue: IVenueResponse | null; + venues: IVenueResponse[] | null; + /** + * Adds a new venue to the system. + * @param venue The details of the venue to add. + * @returns A promise that resolves with the response containing the newly added venue. + */ + addVenue(venue: IAddVenueRequest): Promise; + + /** + * Updates an existing venue. + * @param id The ID of the venue to put. + * @param venue The updated venue details. + * @returns A promise that resolves with the response containing the updated venue. + */ + putVenueById( + id: GUID, + venue: IPutVenueRequest + ): Promise; + + /** + * Uploads a new photo for an existing venue. The image is stored separately + * from the venue's other fields (mirrors the team logo endpoint). Resolves + * with the venue re-fetched from the backend so the caller gets the fresh + * photo URL without a page reload. + * @param id The ID of the venue whose photo to replace. + * @param image The new image file. + */ + putVenuePhotoById(id: GUID, image: File): Promise; + + /** + * Fetches all venues from the system. + * @returns A promise that resolves with an array of venues. + */ + getAllVenues(): Promise; + + /** + * Fetches a specific venue by its unique ID or its public slug. + * @param idOrSlug The ID or slug of the venue to fetch. + * @returns A promise that resolves with the venue data. + */ + getVenueById(idOrSlug: string): Promise; + + /** + * Deletes a venue by its unique ID. Resolves with a discriminated result so + * callers can surface a backend integrity block (a venue referenced by + * matches is rejected with a 409 and a Spanish message). + * @param id The ID of the venue to delete. + */ + deleteVenueById(id: GUID): Promise; +} + +/** + * The request body structure for adding a new venue. + * @interface IAddVenueRequest + */ +export interface IAddVenueRequest { + /** + * The name of the venue. + * @type {string} + */ + name: string; + + /** + * The address of the venue. + * @type {string} + */ + address: string; + + /** + * Optional photo of the venue. A venue does not require an image. + * @type {File} + */ + imageFile?: File | null; + + /** + * Optional geographic latitude of the venue. + * @type {number} + */ + latitude?: number; + + /** + * Optional geographic longitude of the venue. + * @type {number} + */ + longitude?: number; +} + +/** + * The response structure when a venue is created or updated. + * This extends from AddVenueRequest and includes an ID. + * @interface IVenueResponse + */ +export interface IVenueResponse { + /** + * The unique identifier of the venue. + * @type {string} + */ + id: GUID; + /** + * The name of the venue. + * @type {string} + */ + name: string; + + /** + * The unique, URL-friendly identifier used in public venue links. + * @type {string} + */ + slug: string; + + /** + * The address of the venue. + * @type {string} + */ + address: string; + + photoUrl?: string; + + /** + * Optional geographic latitude of the venue, for the public map link. + * @type {number} + */ + latitude?: number; + + /** + * Optional geographic longitude of the venue, for the public map link. + * @type {number} + */ + longitude?: number; +} + +/** + * The request body structure for updating an existing venue. + * It is the same as AddVenueRequest since only the venue details are updated. + * @interface IPutVenueRequest + */ +export interface IPutVenueRequest { + /** + * The name of the venue. + * @type {string} + */ + name: string; + + /** + * The address of the venue. + * @type {string} + */ + address: string; + + /** + * The URL of the venue's photo. + * @type {string} + */ + photoUrl?: string; + + /** + * Optional geographic latitude of the venue. + * @type {number} + */ + latitude?: number; + + /** + * Optional geographic longitude of the venue. + * @type {number} + */ + longitude?: number; +} + +export interface VenueDashboardProps { + venues: IVenueResponse[]; +} diff --git a/Club12-WebClient/src/mui-data-grid.d.ts b/Club12-WebClient/src/mui-data-grid.d.ts new file mode 100644 index 0000000..5aa2184 --- /dev/null +++ b/Club12-WebClient/src/mui-data-grid.d.ts @@ -0,0 +1,15 @@ +import { + ComponentsOverrides, + ComponentsProps, + ComponentsVariants, +} from '@mui/material/styles'; + +declare module '@mui/material/styles' { + interface Components { + MuiDataGrid?: { + defaultProps?: ComponentsProps['MuiDataGrid']; + styleOverrides?: ComponentsOverrides['MuiDataGrid']; + variants?: ComponentsVariants['MuiDataGrid']; + }; + } +} diff --git a/Club12-WebClient/src/test/setup.ts b/Club12-WebClient/src/test/setup.ts new file mode 100644 index 0000000..d935a9d --- /dev/null +++ b/Club12-WebClient/src/test/setup.ts @@ -0,0 +1,12 @@ +import '@testing-library/jest-dom'; + +/** jsdom has no ResizeObserver; guarded so a test file's own more specific stub still wins. */ +class ResizeObserverStub { + observe() {} + unobserve() {} + disconnect() {} +} + +if (!window.ResizeObserver) { + window.ResizeObserver = ResizeObserverStub as unknown as typeof ResizeObserver; +} diff --git a/Club12-WebClient/src/test/smoke.test.tsx b/Club12-WebClient/src/test/smoke.test.tsx new file mode 100644 index 0000000..b72845f --- /dev/null +++ b/Club12-WebClient/src/test/smoke.test.tsx @@ -0,0 +1,11 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import LoadingIndicator from '@/views/core/components/LoadingIndicator'; + +describe('LoadingIndicator', () => { + it('renders the loading text', () => { + render(); + + expect(screen.getByText('Cargando...')).toBeInTheDocument(); + }); +}); diff --git a/Club12-WebClient/src/theme.color-tokens.test.ts b/Club12-WebClient/src/theme.color-tokens.test.ts new file mode 100644 index 0000000..0fb4f27 --- /dev/null +++ b/Club12-WebClient/src/theme.color-tokens.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import theme, { CANCEL_BUTTON_COLOR } from './theme'; + +describe('theme color tokens', () => { + it('primary.main resolves to the modern-sport brand hex #FF5A1F', () => { + expect(theme.palette.primary.main).toBe('#FF5A1F'); + }); + + it('secondary.main resolves to the navy scoreboard hex #0F172A', () => { + expect(theme.palette.secondary.main).toBe('#0F172A'); + }); + + it('CANCEL_BUTTON_COLOR resolves to the original SweetAlert cancel hex #d33', () => { + expect(CANCEL_BUTTON_COLOR).toBe('#d33'); + }); + + it('error.main remains MUI default #d32f2f, unaffected by the new cancel token', () => { + expect(theme.palette.error.main).toBe('#d32f2f'); + }); +}); diff --git a/Club12-WebClient/src/theme.ts b/Club12-WebClient/src/theme.ts new file mode 100644 index 0000000..1779535 --- /dev/null +++ b/Club12-WebClient/src/theme.ts @@ -0,0 +1,328 @@ +import { createTheme, type PaletteMode, type Theme } from '@mui/material/styles'; +import { esES as dataGridEsES } from '@mui/x-data-grid/locales'; +import { + brand, + surface, + ink, + semantic, + dividerColor, + cancelColor, + logoBackground, +} from '@/design/tokens'; + +/* The MUI theme is assembled entirely from the shared design tokens + (`@/design/tokens`) so components and one-off surfaces read the same hues + the theme is built from. These local aliases preserve the original names + used throughout the overrides below without duplicating any hex value. */ +export const CANCEL_BUTTON_COLOR = cancelColor; + +/** + * The Club 12 logo asset has its dark maroon backdrop baked into the PNG + * (no alpha transparency). This is the matching color for the chip wrapping + * the logo wherever it's placed, so the image's rectangular edge reads as + * an intentional badge rather than a stray box. + */ +export const LOGO_BACKGROUND_COLOR = logoBackground; + +/* Brand hues. Orange is the single accent — CTAs, active states, focus rings, + highlights. Navy is the secondary "scoreboard" hue used for chrome. */ +const ORANGE = brand.orange; +const ORANGE_LIGHT = brand.orangeLight; +const ORANGE_DARK = brand.orangeDark; +const NAVY = brand.navy; +const NAVY_LIGHT = brand.navyLight; + +/* Layered dark surfaces (canvas -> paper -> raised). Kept as a deliberate + three-step scale so depth reads through elevation, never through a colored + MUI overlay (see MuiPaper.backgroundImage: 'none' below). */ +const DARK_BG = surface.canvas; // L0 app canvas +const DARK_PAPER = surface.paper; // L1 cards, drawers, app surfaces +const DARK_RAISED = surface.raised; // L2 inputs, menus, hovered rows +const DARK_TEXT_PRIMARY = ink.primary; +const DARK_TEXT_SECONDARY = ink.secondary; +const DARK_DIVIDER = dividerColor.dark; + +/* Near-black ink used as the label color on filled orange (AA-safe: + ~5.6:1 vs #FF5A1F, where white would only reach ~3.1:1). */ +const ORANGE_INK = brand.orangeInk; + +/* Semantic hues tuned to stay legible on the dark canvas. error is left to + MUI's default (#d32f2f) so it matches the SweetAlert cancel affordance. */ +const SUCCESS = semantic.success; +const WARNING = semantic.warning; +const INFO = semantic.info; + +/** + * Builds the MUI theme for the requested color mode. The app is dark-first; + * the light branch is retained for the legacy default export and its token + * test. Both modes share the brand hues (orange accent, navy secondary) but + * derive surface/text tokens independently so components never branch on mode + * themselves — they read from `theme.palette`. + */ +export const getTheme = (mode: PaletteMode): Theme => { + const isDark = mode === 'dark'; + + const baseTheme = createTheme({ + palette: { + mode, + // Bias contrastText toward the darker option so filled-orange controls + // get AA-compliant ink rather than low-contrast white. + contrastThreshold: 4.5, + primary: { + main: ORANGE, + light: ORANGE_LIGHT, + dark: ORANGE_DARK, + contrastText: ORANGE_INK, + }, + secondary: { + main: isDark ? NAVY_LIGHT : NAVY, + light: NAVY_LIGHT, + contrastText: '#fff', + }, + success: { + main: SUCCESS, + }, + warning: { + main: WARNING, + }, + info: { + main: INFO, + }, + background: { + default: isDark ? DARK_BG : '#F4F6F9', + paper: isDark ? DARK_PAPER : '#FFFFFF', + }, + text: { + primary: isDark ? DARK_TEXT_PRIMARY : NAVY, + secondary: isDark ? DARK_TEXT_SECONDARY : '#516072', + }, + divider: isDark ? DARK_DIVIDER : 'rgba(15, 23, 42, 0.12)', + }, + typography: { + fontFamily: "'Roboto', sans-serif", + h1: { fontFamily: "'Oswald', sans-serif", fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em' }, + h2: { fontFamily: "'Oswald', sans-serif", fontWeight: 700, textTransform: 'uppercase', letterSpacing: '0.03em' }, + h3: { fontFamily: "'Oswald', sans-serif", fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.02em' }, + h4: { fontFamily: "'Oswald', sans-serif", fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.02em' }, + h5: { fontFamily: "'Oswald', sans-serif", fontWeight: 600 }, + h6: { fontFamily: "'Oswald', sans-serif", fontWeight: 600 }, + }, + }); + + return createTheme(baseTheme, { + components: { + MuiListItemText: { + styleOverrides: { + primary: { + fontWeight: 'bold', + }, + }, + }, + MuiTableHead: { + styleOverrides: { + root: { + backgroundColor: isDark ? DARK_BG : NAVY, + '& .MuiTableCell-root': { + color: '#fff', + fontWeight: 700, + textTransform: 'uppercase', + fontSize: '0.8rem', + letterSpacing: '0.04em', + }, + }, + }, + }, + MuiTableRow: { + styleOverrides: { + root: { + '&:hover': { + backgroundColor: isDark ? 'rgba(255, 90, 31, 0.12)' : '#FFE9DD', + transition: 'background-color 0.2s ease-in-out', + }, + '&.MuiTableRow-head:hover': { + backgroundColor: isDark ? DARK_BG : NAVY, + }, + }, + }, + }, + MuiButton: { + defaultProps: { + color: 'primary', + }, + styleOverrides: { + root: { + borderRadius: '8px', + textTransform: 'none', + marginTop: '16px', + fontWeight: 'bold', + // Visible keyboard focus ring in the accent hue. + '&.Mui-focusVisible': { + outline: `2px solid ${ORANGE}`, + outlineOffset: '2px', + }, + }, + containedPrimary: { + color: ORANGE_INK, + '&:hover': { + backgroundColor: ORANGE_DARK, + color: '#fff', + }, + }, + }, + }, + MuiCssBaseline: { + styleOverrides: { + body: { + backgroundColor: isDark ? DARK_BG : '#F4F6F9', + }, + // Global accent focus ring for keyboard users across every + // focusable element that doesn't ship its own. + '*:focus-visible': { + outline: `2px solid ${ORANGE}`, + outlineOffset: '2px', + }, + }, + }, + MuiAppBar: { + styleOverrides: { + root: { + backgroundColor: NAVY, + color: '#fff', + // Orange keel-line ties the chrome to the accent identity. + borderBottom: `2px solid ${ORANGE}`, + }, + }, + }, + MuiTextField: { + styleOverrides: { + root: { + backgroundColor: isDark ? DARK_RAISED : '#fff', + borderRadius: '8px', + }, + }, + }, + MuiInputBase: { + styleOverrides: { + root: { + backgroundColor: isDark ? DARK_RAISED : '#fff', + borderRadius: '8px', + }, + }, + }, + MuiOutlinedInput: { + styleOverrides: { + notchedOutline: { + borderColor: isDark ? DARK_DIVIDER : 'rgba(15, 23, 42, 0.23)', + }, + root: { + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: isDark ? 'rgba(231, 234, 240, 0.28)' : 'rgba(15, 23, 42, 0.4)', + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: ORANGE, + borderWidth: '2px', + }, + }, + }, + }, + MuiDataGrid: { + styleOverrides: { + root: { + border: '1px solid', + borderColor: isDark ? DARK_DIVIDER : 'rgba(15, 23, 42, 0.12)', + backgroundColor: isDark ? DARK_PAPER : '#fff', + }, + columnHeader: { + backgroundColor: isDark ? DARK_BG : NAVY, + color: '#fff', + }, + columnHeaderTitle: { + fontWeight: 700, + fontSize: '0.85rem', + textTransform: 'uppercase', + letterSpacing: '0.03em', + }, + columnSeparator: { + color: NAVY_LIGHT, + }, + cell: { + fontSize: '0.95rem', + borderColor: isDark ? DARK_DIVIDER : 'rgba(15, 23, 42, 0.08)', + }, + row: { + '&:hover': { + backgroundColor: isDark ? 'rgba(255, 90, 31, 0.10)' : '#FFE9DD', + }, + '&.Mui-selected': { + backgroundColor: isDark ? 'rgba(255, 90, 31, 0.18)' : 'rgba(255, 90, 31, 0.14)', + '&:hover': { + backgroundColor: isDark ? 'rgba(255, 90, 31, 0.24)' : 'rgba(255, 90, 31, 0.2)', + }, + }, + }, + }, + }, + MuiListItemButton: { + styleOverrides: { + root: { + '&.Mui-selected': { + backgroundColor: ORANGE, + color: ORANGE_INK, + '& .MuiListItemIcon-root': { color: ORANGE_INK }, + '&:hover': { + backgroundColor: ORANGE_DARK, + color: '#fff', + '& .MuiListItemIcon-root': { color: '#fff' }, + }, + }, + }, + }, + }, + MuiMenuItem: { + styleOverrides: { + root: { + '&.Mui-selected': { + backgroundColor: ORANGE, + color: ORANGE_INK, + '&:hover': { + backgroundColor: ORANGE_DARK, + color: '#fff', + }, + }, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + padding: baseTheme.spacing(3), + borderRadius: '10px', + borderTop: `4px solid ${ORANGE}`, + boxShadow: baseTheme.shadows[3], + }, + }, + }, + MuiPaper: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + MuiChip: { + styleOverrides: { + root: { + fontWeight: 700, + textTransform: 'uppercase', + fontSize: '0.7rem', + letterSpacing: '0.03em', + }, + }, + }, + }, + }, dataGridEsES); +}; + +const theme = getTheme('light'); + +export default theme; diff --git a/Club12-WebClient/src/views/auth/PasswordPolicyChecklist.tsx b/Club12-WebClient/src/views/auth/PasswordPolicyChecklist.tsx new file mode 100644 index 0000000..5f726cd --- /dev/null +++ b/Club12-WebClient/src/views/auth/PasswordPolicyChecklist.tsx @@ -0,0 +1,34 @@ +import { Divider, Stack, Typography } from '@mui/material'; +import { + PASSWORD_POLICY_RULES, + getPasswordPolicyState, +} from '@/modules/auth/utils/passwordPolicy'; + +/** + * Live checklist of the password rules (HU-09/HU-10). Each rule turns green + * once the typed password satisfies it. Shared by the activation and + * password-reset screens so both show identical wording and behaviour. + */ +export default function PasswordPolicyChecklist({ + password, +}: { + password: string; +}) { + const policy = getPasswordPolicyState(password); + + return ( + + Reglas de contraseña + + {PASSWORD_POLICY_RULES.map(rule => ( + + {policy[rule.key] ? '✓' : '•'} {rule.label} + + ))} + + ); +} diff --git a/Club12-WebClient/src/views/auth/activateAccount.tsx b/Club12-WebClient/src/views/auth/activateAccount.tsx new file mode 100644 index 0000000..f1dad36 --- /dev/null +++ b/Club12-WebClient/src/views/auth/activateAccount.tsx @@ -0,0 +1,149 @@ +import { AxiosError } from 'axios'; +import { useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { + Box, + Button, + Card, + CardContent, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { authService } from '@/modules/auth/service/auth.service'; +import { useError } from '@/modules/error/hooks/error.hock'; +import InvalidToken from '@/views/core/errors/invalidToken'; +import PasswordPolicyChecklist from '@/views/auth/PasswordPolicyChecklist'; +import { buildPasswordPolicyMessages } from '@/modules/auth/utils/passwordPolicy'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; +import { APP_ROUTES } from '@/modules/core/constants/appRoutes'; + +/** + * HU-09: activation screen reached from the magic link in the invitation + * email. Reads `?email=&token=` from the URL, lets the invited user set their + * first password, and posts it to POST /api/auth/activate. On success the + * account can log in, so we send the user to the login screen. + */ +export default function ActivateAccount() { + const navigate = useNavigate(); + const location = useLocation(); + const { setError, setMessage } = useError(); + + const normalizedSearchParams = useMemo(() => { + const normalizedSearch = location.search.replace(/&/gi, '&'); + return new URLSearchParams(normalizedSearch); + }, [location.search]); + + const readQueryParam = (key: string) => + normalizedSearchParams.get(key)?.trim() ?? + normalizedSearchParams.get(`amp;${key}`)?.trim() ?? + ''; + + const email = readQueryParam('email'); + const token = readQueryParam('token'); + + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const hasRequiredParams = email.length > 0 && token.length > 0; + + const handleSubmit = async () => { + const messages = buildPasswordPolicyMessages(newPassword, confirmPassword); + + if (!hasRequiredParams) { + messages.unshift('El enlace de activación no es válido.'); + } + + if (messages.length > 0) { + setMessage(HttpStatus.BadRequest, messages); + return; + } + + setSubmitting(true); + try { + const response = await authService.activateRequest({ + email, + token, + newPassword, + }); + + if (response?.status === HttpStatus.Ok) { + setMessage(HttpStatus.Ok, [ + 'Cuenta activada correctamente. Iniciá sesión.', + ]); + navigate(APP_ROUTES.login, { replace: true }); + } + } catch (error: unknown) { + setError(error as AxiosError); + } finally { + setSubmitting(false); + } + }; + + if (!hasRequiredParams) { + return ; + } + + return ( + + + + + Activá tu cuenta + + + + + + setNewPassword(e.target.value)} + /> + + + + setConfirmPassword(e.target.value)} + /> + + + + + + + + + + ); +} diff --git a/Club12-WebClient/src/views/auth/forgotPassword.tsx b/Club12-WebClient/src/views/auth/forgotPassword.tsx new file mode 100644 index 0000000..61f8bba --- /dev/null +++ b/Club12-WebClient/src/views/auth/forgotPassword.tsx @@ -0,0 +1,128 @@ +import { AxiosError } from 'axios'; +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + Button, + Card, + CardContent, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { authService } from '@/modules/auth/service/auth.service'; +import { useError } from '@/modules/error/hooks/error.hock'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; +import { APP_ROUTES } from '@/modules/core/constants/appRoutes'; +import { + isValidEmail, + VALIDATION_MESSAGES, +} from '@/modules/core/utils/validators'; + +/** + * HU-10: self-service "Olvidé mi contraseña" screen. Posts the email to + * POST /api/auth/password-reset/request, which always succeeds (no account + * enumeration) and emails a magic reset link consumed by PasswordReset. + */ +export default function ForgotPassword() { + const navigate = useNavigate(); + const { setError, setMessage } = useError(); + + const [email, setEmail] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [sent, setSent] = useState(false); + + const emailError = email.length > 0 && !isValidEmail(email); + + const handleSubmit = async () => { + if (!email.trim()) { + setMessage(HttpStatus.BadRequest, ['El email es obligatorio.']); + return; + } + + if (!isValidEmail(email)) { + setMessage(HttpStatus.BadRequest, [VALIDATION_MESSAGES.email + '.']); + return; + } + + setSubmitting(true); + try { + const response = await authService.requestPasswordResetRequest({ + email: email.trim(), + }); + + if (response?.status === HttpStatus.Ok) { + setSent(true); + setMessage(HttpStatus.Ok, [ + 'Si el email existe, te enviamos un link para restablecer tu contraseña.', + ]); + } + } catch (error: unknown) { + setError(error as AxiosError); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + Olvidé mi contraseña + + + Ingresá tu email y te enviaremos un link para restablecerla. + + + + setEmail(e.target.value)} + disabled={sent} + error={emailError} + helperText={emailError ? VALIDATION_MESSAGES.email : undefined} + onKeyDown={e => { + if (e.key === 'Enter') { + void handleSubmit(); + } + }} + /> + + + + + + + + + + ); +} diff --git a/Club12-WebClient/src/views/auth/login.tsx b/Club12-WebClient/src/views/auth/login.tsx new file mode 100644 index 0000000..15ee288 --- /dev/null +++ b/Club12-WebClient/src/views/auth/login.tsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Box, + TextField, + Button, + Link, + Typography, + Card, + CardContent, + useTheme, +} from '@mui/material'; +import { useAuth } from '@/modules/auth/hook/auth.hook'; +import { LogInUserRequest } from '@/modules/auth/type/auth'; +import { APP_ROUTES } from '@/modules/core/constants/appRoutes'; + +export default function Login() { + const theme = useTheme(); + const navigate = useNavigate(); + const { signIn } = useAuth(); + const [credentials, setCredentials] = useState({ + email: '', + password: '', + }); + + const handleChange = (e: React.ChangeEvent) => { + setCredentials({ ...credentials, [e.target.name]: e.target.value }); + }; + + const handleLogin = async () => { + // A failed sign-in already shows the standard Spanish toast (setMessage + // inside signIn) — nothing to do here beyond navigating on success. + const success = await signIn(credentials); + if (success) { + navigate(APP_ROUTES.panel); + } + }; + + return ( + + + + + Administrador + + + { + if (e.key === 'Enter') { + void handleLogin(); + } + }} + /> + + + navigate(APP_ROUTES.forgotPassword)} + > + ¿Olvidaste tu contraseña? + + + + + + ); +} diff --git a/Club12-WebClient/src/views/auth/passwordReset.tsx b/Club12-WebClient/src/views/auth/passwordReset.tsx new file mode 100644 index 0000000..cca6299 --- /dev/null +++ b/Club12-WebClient/src/views/auth/passwordReset.tsx @@ -0,0 +1,142 @@ +import { AxiosError } from 'axios'; +import { useMemo, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { + Box, + Button, + Card, + CardContent, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { authService } from '@/modules/auth/service/auth.service'; +import { useError } from '@/modules/error/hooks/error.hock'; +import InvalidToken from '@/views/core/errors/invalidToken'; +import PasswordPolicyChecklist from '@/views/auth/PasswordPolicyChecklist'; +import { buildPasswordPolicyMessages } from '@/modules/auth/utils/passwordPolicy'; +import { HttpStatus } from '@/modules/core/constants/httpStatus'; +import { APP_ROUTES } from '@/modules/core/constants/appRoutes'; + +export default function PasswordReset() { + const navigate = useNavigate(); + const location = useLocation(); + const { setError, setMessage } = useError(); + + const normalizedSearchParams = useMemo(() => { + const normalizedSearch = location.search.replace(/&/gi, '&'); + return new URLSearchParams(normalizedSearch); + }, [location.search]); + + const readQueryParam = (key: string) => + normalizedSearchParams.get(key)?.trim() ?? + normalizedSearchParams.get(`amp;${key}`)?.trim() ?? + ''; + + const email = readQueryParam('email'); + const token = readQueryParam('token'); + + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const hasRequiredParams = email.length > 0 && token.length > 0; + + const handleSubmit = async () => { + const messages = buildPasswordPolicyMessages(newPassword, confirmPassword); + + if (!hasRequiredParams) { + messages.unshift('El enlace de recuperación no es válido.'); + } + + if (messages.length > 0) { + setMessage(HttpStatus.BadRequest, messages); + return; + } + + setSubmitting(true); + try { + const response = await authService.confirmPasswordResetRequest({ + email, + token, + newPassword, + }); + + if (response?.status === HttpStatus.Ok) { + setMessage(HttpStatus.Ok, [ + 'Contraseña actualizada correctamente. Iniciá sesión.', + ]); + navigate(APP_ROUTES.login, { replace: true }); + } + } catch (error: unknown) { + setError(error as AxiosError); + } finally { + setSubmitting(false); + } + }; + + if (!hasRequiredParams) { + return ; + } + + return ( + + + + + Restablecer contraseña + + + + + + setNewPassword(e.target.value)} + /> + + + + setConfirmPassword(e.target.value)} + /> + + + + + + + + + + ); +} diff --git a/Club12-WebClient/src/views/blogPost/BlogListPage.tsx b/Club12-WebClient/src/views/blogPost/BlogListPage.tsx new file mode 100644 index 0000000..df64f40 --- /dev/null +++ b/Club12-WebClient/src/views/blogPost/BlogListPage.tsx @@ -0,0 +1,32 @@ +import { Typography } from '@mui/material'; +import PageShell from '@/views/core/components/PageShell'; +import ShowPosts from '@/views/blogPost/showPosts'; +import { + DEFAULT_PAGE_METADATA, + usePageMetadata, +} from '@/modules/core/utils/pageMetadata'; + +export default function BlogListPage() { + usePageMetadata({ + ...DEFAULT_PAGE_METADATA, + title: 'Novedades', + description: + 'Últimas noticias, resultados y novedades de la liga de básquet ' + + 'Club 12.', + }); + + return ( + + + Últimas noticias de la liga Club 12. + + + + ); +} diff --git a/Club12-WebClient/src/views/blogPost/BlogPostDetailPage.test.tsx b/Club12-WebClient/src/views/blogPost/BlogPostDetailPage.test.tsx new file mode 100644 index 0000000..ee361c5 --- /dev/null +++ b/Club12-WebClient/src/views/blogPost/BlogPostDetailPage.test.tsx @@ -0,0 +1,332 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { StrictMode } from 'react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import BlogPostDetailPage from '@/views/blogPost/BlogPostDetailPage'; +import { useBlogPost } from '@/modules/blogPost/hook/blogPost.hook'; +import { useAuth } from '@/modules/auth/hook/auth.hook'; +import { UserRolesType } from '@/modules/core/enum/user/userRolesType'; +import { BlogPostProvider } from '@/modules/blogPost/context/blogPost.context'; +import { ErrorProvider } from '@/modules/error/context/error.context'; +import { sendGet } from '@/modules/core/utils/axiosUtils'; +import type { BlogPostResponse } from '@/modules/blogPost/type/blogPost'; +import type { GUID } from '@/modules/core/types/types'; + +vi.mock('@/modules/blogPost/hook/blogPost.hook'); +vi.mock('@/modules/auth/hook/auth.hook'); +vi.mock('@/modules/core/utils/axiosUtils', () => ({ + sendGet: vi.fn(), + sendPost: vi.fn(), + sendPut: vi.fn(), + sendDelete: vi.fn(), +})); +vi.mock('sweetalert2', () => ({ + default: { fire: vi.fn() }, +})); + +const mockedUseBlogPost = vi.mocked(useBlogPost); +const mockedUseAuth = vi.mocked(useAuth); +const mockedSendGet = vi.mocked(sendGet); + +/** + * A promise whose resolution is controlled by the test, so the in-flight + * window of the background GET is inspectable instead of racing `waitFor`. + */ +const deferred = (): { + promise: Promise; + resolve: (value: T) => void; +} => { + let resolve!: (value: T) => void; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +}; + +const buildPost = ( + overrides: Partial = {} +): BlogPostResponse => ({ + id: 'guid-a-aaaa-bbbb-cccc' as unknown as GUID, + author: 'Autor', + title: 'Titulo', + slug: 'titulo', + views: 0, + markdownText: 'contenido', + createdAt: new Date('2026-01-01'), + isPublished: true, + ...overrides, +}); + +const renderAt = (path: string, state?: unknown) => + render( + + + } /> + + + ); + +const withGetBlogPostsById = (getBlogPostsById: ReturnType) => { + mockedUseBlogPost.mockReturnValue({ + getBlogPostsById, + } as unknown as ReturnType); +}; + +beforeEach(() => { + mockedUseAuth.mockReturnValue({ + role: UserRolesType.Guest, + } as unknown as ReturnType); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('BlogPostDetailPage', () => { + it('renders the post from router state and still fires exactly one background GET', async () => { + const pending = deferred(); + const getBlogPostsById = vi.fn().mockReturnValue(pending.promise); + withGetBlogPostsById(getBlogPostsById); + + renderAt('/blog/mi-slug', { + post: buildPost({ slug: 'mi-slug', title: 'Post desde el estado' }), + }); + + expect(screen.getByText('Post desde el estado')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + await waitFor(() => + expect(getBlogPostsById).toHaveBeenCalledTimes(1) + ); + expect(getBlogPostsById).toHaveBeenCalledWith('mi-slug', { silent: true }); + + await act(async () => { + pending.resolve( + buildPost({ slug: 'mi-slug', title: 'Post desde el estado' }) + ); + }); + }); + + it('replaces the state post with the fetched copy on resolve', async () => { + const pending = deferred(); + const getBlogPostsById = vi.fn().mockReturnValue(pending.promise); + withGetBlogPostsById(getBlogPostsById); + + renderAt('/blog/mi-slug', { + post: buildPost({ slug: 'mi-slug', title: 'Post desde el estado' }), + }); + + expect(screen.getByText('Post desde el estado')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + + await act(async () => { + pending.resolve( + buildPost({ slug: 'mi-slug', title: 'Post del servidor', views: 8 }) + ); + }); + + expect(await screen.findByText('Post del servidor')).toBeInTheDocument(); + expect(screen.queryByText('Post desde el estado')).not.toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('keeps showing the state post and raises no alert when the background GET fails', async () => { + const Swal = (await import('sweetalert2')).default; + const getBlogPostsById = vi.fn().mockResolvedValue(undefined); + withGetBlogPostsById(getBlogPostsById); + + renderAt('/blog/mi-slug', { + post: buildPost({ slug: 'mi-slug', title: 'Post desde el estado' }), + }); + + await waitFor(() => + expect(getBlogPostsById).toHaveBeenCalledWith('mi-slug', { silent: true }) + ); + + expect(await screen.findByText('Post desde el estado')).toBeInTheDocument(); + expect( + screen.queryByText('Publicación no encontrada') + ).not.toBeInTheDocument(); + expect(Swal.fire).not.toHaveBeenCalled(); + }); + + it('fetches the post by id when no router state is present', async () => { + const getBlogPostsById = vi + .fn() + .mockResolvedValue(buildPost({ title: 'Post recargado' })); + withGetBlogPostsById(getBlogPostsById); + + renderAt('/blog/guid-a-aaaa-bbbb-cccc'); + + expect(await screen.findByText('Post recargado')).toBeInTheDocument(); + expect(getBlogPostsById).toHaveBeenCalledWith('guid-a-aaaa-bbbb-cccc', { + silent: true, + }); + expect(getBlogPostsById).toHaveBeenCalledTimes(1); + }); + + it('shows a not-found page when the fetched post does not exist', async () => { + const getBlogPostsById = vi.fn().mockResolvedValue(undefined); + withGetBlogPostsById(getBlogPostsById); + + renderAt('/blog/guid-a-aaaa-bbbb-cccc'); + + await waitFor(() => + expect(screen.getByText('Publicación no encontrada')).toBeInTheDocument() + ); + }); + + it('shows the skeleton while the cold fetch is in flight', async () => { + const pending = deferred(); + const getBlogPostsById = vi.fn().mockReturnValue(pending.promise); + withGetBlogPostsById(getBlogPostsById); + + renderAt('/blog/guid-a-aaaa-bbbb-cccc'); + + expect(screen.getByRole('status')).toBeInTheDocument(); + + await act(async () => { + pending.resolve(buildPost({ title: 'Post recargado' })); + }); + + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); + + it('fires the background GET only once under a StrictMode double mount', async () => { + const getBlogPostsById = vi + .fn() + .mockResolvedValue( + buildPost({ slug: 'mi-slug', title: 'Post desde el estado' }) + ); + withGetBlogPostsById(getBlogPostsById); + + render( + + + + } /> + + + + ); + + await act(async () => { + await Promise.resolve(); + }); + + expect(getBlogPostsById).toHaveBeenCalledTimes(1); + expect(getBlogPostsById).toHaveBeenCalledWith('mi-slug', { silent: true }); + }); + + it('fires the background GET again after a real remount for the same slug', async () => { + const getBlogPostsById = vi + .fn() + .mockResolvedValue(buildPost({ slug: 'mi-slug' })); + withGetBlogPostsById(getBlogPostsById); + + const { unmount } = renderAt('/blog/mi-slug', { + post: buildPost({ slug: 'mi-slug' }), + }); + await act(async () => { + await Promise.resolve(); + }); + unmount(); + + renderAt('/blog/mi-slug', { post: buildPost({ slug: 'mi-slug' }) }); + await act(async () => { + await Promise.resolve(); + }); + + expect(getBlogPostsById).toHaveBeenCalledTimes(2); + }); +}); + +describe('BlogPostDetailPage — network boundary', () => { + beforeEach(async () => { + const actual = await vi.importActual< + typeof import('@/modules/blogPost/hook/blogPost.hook') + >('@/modules/blogPost/hook/blogPost.hook'); + mockedUseBlogPost.mockImplementation(actual.useBlogPost); + }); + + const renderWithRealProviders = (client: QueryClient) => + render( + + + + + + + } + /> + + + + + + + ); + + it('issues one network GET per mount, even across remounts', async () => { + mockedSendGet.mockResolvedValue({ + data: buildPost({ slug: 'mi-slug' }), + } as Awaited>); + const client = new QueryClient(); + + const { unmount } = renderWithRealProviders(client); + await waitFor(() => expect(mockedSendGet).toHaveBeenCalledTimes(1)); + + unmount(); + + renderWithRealProviders(client); + await waitFor(() => expect(mockedSendGet).toHaveBeenCalledTimes(2)); + }); +}); + +describe('BlogPostDetailPage — Editar trigger', () => { + it('hides the edit button from a guest (public reader)', async () => { + withGetBlogPostsById(vi.fn().mockResolvedValue(buildPost())); + + renderAt('/blog/titulo', { post: buildPost() }); + + await screen.findByText('Titulo'); + expect( + screen.queryByRole('button', { name: 'Editar publicación' }) + ).not.toBeInTheDocument(); + }); + + it('shows the edit button to an admin/owner', async () => { + mockedUseAuth.mockReturnValue({ + role: UserRolesType.Admin, + } as unknown as ReturnType); + withGetBlogPostsById(vi.fn().mockResolvedValue(buildPost())); + + renderAt('/blog/titulo', { post: buildPost() }); + + expect( + await screen.findByRole('button', { name: 'Editar publicación' }) + ).toBeInTheDocument(); + }); +}); diff --git a/Club12-WebClient/src/views/blogPost/BlogPostDetailPage.tsx b/Club12-WebClient/src/views/blogPost/BlogPostDetailPage.tsx new file mode 100644 index 0000000..b82e2e9 --- /dev/null +++ b/Club12-WebClient/src/views/blogPost/BlogPostDetailPage.tsx @@ -0,0 +1,200 @@ +import { useEffect, useRef, useState } from 'react'; +import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { formatDateAr } from '@/modules/core/utils/formatDate'; +import { Box, Button, Stack, Typography } from '@mui/material'; +import { useBlogPost } from '@/modules/blogPost/hook/blogPost.hook'; +import { BlogPostResponse } from '@/modules/blogPost/type/blogPost'; +import { useAuth } from '@/modules/auth/hook/auth.hook'; +import { UserRolesType } from '@/modules/core/enum/user/userRolesType'; +import { APP_ROUTES } from '@/modules/core/constants/appRoutes'; +import ErrorPageLayout from '@/views/core/components/ErrorPageLayout'; +import ErrorPageActions from '@/views/core/components/ErrorPageActions'; +import PageShell from '@/views/core/components/PageShell'; +import { DetailSkeleton } from '@/views/core/components/skeletons'; +import { usePageMetadata } from '@/modules/core/utils/pageMetadata'; + +const BLOG_META_DESCRIPTION_LENGTH = 200; + +/** Reduces the post's HTML body to a plain-text excerpt for social cards. */ +const buildDescription = (html: string): string => { + const withoutTags = html.replace(/<[^>]*>/g, ' '); + const decoder = document.createElement('textarea'); + decoder.innerHTML = withoutTags; + const text = decoder.value.replace(/\s+/g, ' ').trim(); + return text.length > BLOG_META_DESCRIPTION_LENGTH + ? `${text.slice(0, BLOG_META_DESCRIPTION_LENGTH)}…` + : text; +}; + +interface BlogPostLocationState { + post?: BlogPostResponse; +} + +const BlogPostDetailPage: React.FC = () => { + const { idOrSlug } = useParams<{ idOrSlug: string }>(); + const location = useLocation(); + const navigate = useNavigate(); + const { getBlogPostsById } = useBlogPost(); + const { role } = useAuth(); + const isAdminOrOwner = role === UserRolesType.Admin || role === UserRolesType.Owner; + const seededPost = (location.state as BlogPostLocationState | undefined)?.post; + const [post, setPost] = useState(seededPost); + const [loading, setLoading] = useState(!seededPost); + + /** + * The `idOrSlug` the currently displayed post belongs to, or undefined when + * nothing is displayed. Deliberately a ref, not state: the fetch effect has to + * consult it, and taking `post` as a dependency would re-run the effect on + * every setPost — with staleTime 0 that is an unbounded fetch/increment loop. + * Seeding it with `idOrSlug` is sound because the navigation that supplies + * `location.state.post` builds the URL from that same post. + */ + const routeKeyRef = useRef( + seededPost ? idOrSlug : undefined + ); + + /** + * The `idOrSlug` the background GET has already been fired for. React + * StrictMode (dev) mounts every component twice, and in the browser the gap + * between the two mounts is long enough for the first GET to resolve — with + * `staleTime` 0 the second mount then fires a SECOND GET and a second + * `Views++` on the server (in dev the counter moved "de a 2"). Firing at most + * once per `idOrSlug` collapses that. A real navigation unmounts this + * component and resets the ref, so re-opening a post still counts. + */ + const requestedForRef = useRef(undefined); + + useEffect(() => { + if (!idOrSlug || requestedForRef.current === idOrSlug) return; + requestedForRef.current = idOrSlug; + const requestedFor = idOrSlug; + + // The GET is the *only* thing that increments Views on the server, so it + // must fire on the router-state path too — skipping it there is what kept + // the "Vistas" column flat. + // + // COUPLING: relies on the QueryClient keeping staleTime 0 + // (QueryProvider.tsx) so fetchQuery reaches the network. A non-zero + // staleTime — global or per-query — would serve this from cache and + // silently stop the counter. + // + // Only blank the page when nothing for THIS route is on screen. When the + // post came in via location.state the refresh runs invisibly underneath it. + if (routeKeyRef.current !== requestedFor) setLoading(true); + + const loadPost = async () => { + try { + // silent: a failed refresh must not raise the global blocking alert + // over an article the reader is already reading. + const fetchedPost = await getBlogPostsById(requestedFor, { + silent: true, + }); + // A later navigation has superseded this request. + if (requestedForRef.current !== requestedFor) return; + + if (fetchedPost) { + setPost(fetchedPost); + routeKeyRef.current = requestedFor; + } else if (routeKeyRef.current !== requestedFor) { + // Cold path only. `undefined` conflates 404 / 500 / offline, so it + // must not tear down an article that is already readable. + setPost(undefined); + } + } finally { + if (requestedForRef.current === requestedFor) setLoading(false); + } + }; + + void loadPost(); + }, [idOrSlug, getBlogPostsById]); + + // HU-17: set per-post Open Graph / Twitter tags so a shared blog URL renders + // a rich card (title, description, image). Empty strings while the post is + // still loading leave the index.html defaults in place. + usePageMetadata({ + title: post?.title, + description: post ? buildDescription(post.markdownText) : undefined, + // A crawler has to be able to fetch og:image, so a locally-rendered cover + // (a "data:" SVG) is left out and the index.html default stands. + image: post?.photoUrl?.startsWith('http') ? post.photoUrl : undefined, + type: 'article', + }); + + if (loading) { + return ( + + + + ); + } + + if (!post) { + return ( + + + Publicación no encontrada + + + La publicación que estás buscando no existe o fue eliminada. + + + + ); + } + + return ( + + + + + {post.title} + + {isAdminOrOwner && ( + + )} + + + {post.author} · {formatDateAr(post.createdAt)} + + {post.photoUrl && ( + + )} +
+ + + ); +}; + +export default BlogPostDetailPage; diff --git a/Club12-WebClient/src/views/blogPost/BlogPostEditPage.test.tsx b/Club12-WebClient/src/views/blogPost/BlogPostEditPage.test.tsx new file mode 100644 index 0000000..12758a6 --- /dev/null +++ b/Club12-WebClient/src/views/blogPost/BlogPostEditPage.test.tsx @@ -0,0 +1,222 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import BlogPostEditPage from '@/views/blogPost/BlogPostEditPage'; +import { useBlogPost } from '@/modules/blogPost/hook/blogPost.hook'; +import * as confirmDialog from '@/modules/core/utils/confirmDialog'; +import type { BlogPostResponse } from '@/modules/blogPost/type/blogPost'; +import type { GUID } from '@/modules/core/types/types'; + +vi.mock('@/modules/blogPost/hook/blogPost.hook'); +vi.mock('react-quill-new', () => ({ + default: ({ + value, + onChange, + }: { + value: string; + onChange: (content: string) => void; + }) => ( +